From f612669610b3c129f0c92ac3412f3c0b0b320943 Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Sat, 19 Sep 2026 11:38:36 +0000 Subject: [PATCH 1/5] Add Console-aware Sovereign Cloud setup and LocalBox health checks --- .../01_Sovereign_Cloud/Readme.md | 6 +- .../labautomation/deploy-lab.ps1 | 1 + .../labautomation/deploy-localbox.ps1 | 5 +- .../labautomation/main.bicep | 2 +- .../labautomation/resource-providers.ps1 | 1 + .../labautomation/shared-deploy-lab.ps1 | 13 + .../labautomation/sovereign-lab.bicep | 3 + .../resources/demo-vm-creator/README.md | 260 +--------- .../resources/hosted-events/readme.md | 52 ++ .../img/add_logical_network_01.jpg | Bin .../img/add_logical_network_02.jpg | Bin .../img/add_logical_network_03.jpg | Bin .../img/add_rbac_01.jpg | Bin .../img/add_rbac_02.jpg | Bin .../img/add_rbac_03.jpg | Bin .../img/add_rbac_04.jpg | Bin .../img/add_rbac_05.jpg | Bin .../img/add_vm_image_win_01.jpg | Bin .../img/add_vm_image_win_02.png | Bin .../img/add_vm_image_win_03.png | Bin .../img/localbox_storage_01.png | Bin .../img/localbox_storage_02.png | Bin .../resources/localbox/manual-preparation.md | 107 ++++ .../resources/localbox/readme.md | 67 +++ .../cleanup/1-resource-groups.ps1 | 0 .../localbox}/deploy-localbox.ps1 | 0 .../resources/manual-setup/readme.md | 33 ++ .../1-resource-providers.ps1 | 1 + .../2-vcpu-quotas.ps1 | 0 .../subscription-preparations/3-rbac.ps1 | 0 .../4-resource-groups.ps1 | 0 .../resources/prepare-localbox.ps1 | 475 ++++++++++++++++++ .../01_Sovereign_Cloud/resources/readme.md | 24 + .../resources/test-sovereign-cloud.ps1 | 152 ++++++ .../resources/tests/localbox.health.tests.ps1 | 99 ++++ .../tests/prepare-localbox.tests.ps1 | 201 ++++++++ .../resources/tests/readme.md | 85 ++++ .../tests/sovereign-lab.health.tests.ps1 | 118 +++++ 38 files changed, 1444 insertions(+), 261 deletions(-) create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_logical_network_01.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_logical_network_02.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_logical_network_03.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_rbac_01.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_rbac_02.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_rbac_03.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_rbac_04.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_rbac_05.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_vm_image_win_01.jpg (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_vm_image_win_02.png (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/add_vm_image_win_03.png (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/localbox_storage_01.png (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => localbox}/img/localbox_storage_02.png (100%) create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/readme.md rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{ => manual-setup}/cleanup/1-resource-groups.ps1 (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{demo-vm-creator => manual-setup/localbox}/deploy-localbox.ps1 (100%) create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/readme.md rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{ => manual-setup}/subscription-preparations/1-resource-providers.ps1 (99%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{ => manual-setup}/subscription-preparations/2-vcpu-quotas.ps1 (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{ => manual-setup}/subscription-preparations/3-rbac.ps1 (100%) rename 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/{ => manual-setup}/subscription-preparations/4-resource-groups.ps1 (100%) create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/readme.md create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/prepare-localbox.tests.ps1 create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/readme.md create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/sovereign-lab.health.tests.ps1 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 9bd601e19..c0ce4a87f 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md @@ -58,7 +58,7 @@ This MicroHack has a few but important prerequisites In order to use the MicroHack time most effectively, the following tasks should be completed prior to starting the session. > [!NOTE] -> Prerequisites 1 - 3 are handled by the organizers for events hosted by Microsoft. +> For eligible Microsoft-hosted events, use the [Hacks Console runbook](./resources/hosted-events/readme.md). Outside those events, use [manual infrastructure setup](./resources/manual-setup/readme.md). Both paths require LocalBox preparation and health validation before participants begin. 1. Your own Azure subscription with Owner RBAC rights at the subscription level 2. Contributor or Owner permissions on your subscription or resource group @@ -71,7 +71,7 @@ The shared Challenge 4/5/7 platform requires **4 AMD SEV-SNP confidential-family The default candidate order is Sweden Central/v5, Spain Central/v5, Sweden Central/v6, then Spain Central/v6. Shared preparation persists the first candidate with supported VM sizes and sufficient confidential-family, DSv5-family, and regional quota so every participant deployment in that subscription uses the same selection. ```powershell -./resources/subscription-preparations/2-vcpu-quotas.ps1 -Region swedencentral -NumberOfLabUsers 2 -ConfidentialVmGeneration v5 -SubmitQuotaRequests +./resources/manual-setup/subscription-preparations/2-vcpu-quotas.ps1 -Region swedencentral -NumberOfLabUsers 2 -ConfidentialVmGeneration v5 -SubmitQuotaRequests ``` Run the check for `spaincentral` as well if it should remain available for fallback. If v5 is restricted for the subscription, repeat the checks with `-ConfidentialVmGeneration v6`. @@ -92,7 +92,7 @@ Use the AZQR report for planning and preferred-region ordering, not as the deplo The main cost driver for this MicroHack is virtual machines: -- **LocalBox** cost is approximately 100-110 USD per day. We recommend setting it up the week before the event, so for example 5 days before the event would result in a cost between 5-600 USD. +- **LocalBox** cost is approximately 100-110 USD per subscription per day. 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](./resources/hosted-events/readme.md). - **Challenges 4, 5, and 7** share one pre-provisioned participant platform: a two-node AKS system pool, one Confidential VM AKS node, one standalone Confidential VM, one K3s VM, Azure Bastion Standard, and a NAT Gateway. Budget approximately 45-55 USD per participant per day, depending on region and data transfer. The platform starts during lab deployment so participants can focus on validation rather than waiting for capacity-sensitive resources. - Plan subscription quotas for at least 12 general-purpose vCPUs and 4 DCasv5- or DCasv6-family confidential vCPUs per participant. Keep the default maximum of two participants per subscription unless larger aggregate increases have been approved in advance. 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 5094c839e..530d5871a 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 @@ -163,6 +163,7 @@ $sovereignLabResult = Invoke-MhhDeploymentWithRegionFallback ` -Tag @{ workload = 'sovereign-lab' challenges = '4,5,7' + CostControl = 'Ignore' } # Assign the lab-specific subscription-scoped RBAC (Security Reader + Resource Policy diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 index 8b4b8c8a5..623fccb41 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 @@ -230,13 +230,13 @@ if ($resourceGroupExists -eq 'true') { ) Invoke-AzJson -Arguments @( 'group', 'update', '--subscription', $SubscriptionId, '--name', $ResourceGroupName, - '--set', 'tags.workload=sovereign-localbox', 'tags.challenge=6', 'tags.SecurityControl=Ignore' + '--set', 'tags.workload=sovereign-localbox', 'tags.challenge=6', 'tags.SecurityControl=Ignore', 'tags.CostControl=Ignore' ) | Out-Null } else { $resourceGroup = Invoke-AzJson -Arguments @( 'group', 'create', '--subscription', $SubscriptionId, '--name', $ResourceGroupName, - '--location', $Location, '--tags', 'workload=sovereign-localbox', 'challenge=6', 'SecurityControl=Ignore' + '--location', $Location, '--tags', 'workload=sovereign-localbox', 'challenge=6', 'SecurityControl=Ignore', 'CostControl=Ignore' ) } Write-Host "Resource group ready: $($resourceGroup.name) ($($resourceGroup.location))" -ForegroundColor Green @@ -281,6 +281,7 @@ try { tags = @{ value = @{ Project = 'jumpstart_LocalBox' SecurityControl = 'Ignore' + CostControl = 'Ignore' } } governResourceTags = @{ value = $false } } 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 4743633de..d5d6baff1 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 @@ -3,7 +3,7 @@ // deploymentType = resourcegroup: the platform pre-creates one resource group // per participant in the shared subscription and grants the participant Owner on // that resource group plus Reader on the subscription. This template adds the -// same subscription-scoped permissions that resources/subscription-preparations/3-rbac.ps1 +// same subscription-scoped permissions that resources/manual-setup/subscription-preparations/3-rbac.ps1 // previously assigned to the LabUsers group: // - Security Reader (view Defender for Cloud secure score / recommendations) // - Resource Policy Contributor (author and assign Azure Policy at subscription scope) diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 index 90278654d..6b59e9c21 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/resource-providers.ps1 @@ -49,6 +49,7 @@ $providers = @( # Azure Local (Stack HCI) "Microsoft.AzureStackHCI", + "Microsoft.EdgeMarketplace", "Microsoft.ResourceConnector", "Microsoft.HybridContainerService", 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 b88d17d1c..ddd8707ce 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 @@ -168,6 +168,7 @@ if ($resourceGroup) { 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 @@ -186,6 +187,18 @@ $localBoxDeployment = if ($resourceGroup) { 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) { diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep index 7e44a0ce2..9f957ecd7 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/sovereign-lab.bicep @@ -46,6 +46,7 @@ var cvmPrivateIp = '10.42.2.4' var tags = { workload: 'sovereign-lab' challenges: '4,5,7' + CostControl: 'Ignore' } var k3sInstallScript = ''' @@ -430,6 +431,7 @@ resource aks 'Microsoft.ContainerService/managedClusters@2024-10-01' = { { name: 'system' count: 2 + tags: tags vmSize: aksNodeVmSize osType: 'Linux' osSKU: 'Ubuntu' @@ -453,6 +455,7 @@ resource confidentialNodePool 'Microsoft.ContainerService/managedClusters/agentP parent: aks properties: { count: 1 + tags: tags vmSize: confidentialVmSize osType: 'Linux' osSKU: 'AzureLinux' diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md index cbe6983f0..b6c002da5 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/README.md @@ -1,259 +1,9 @@ -# Deploy Azure Arc Jumpstart LocalBox +# LocalBox guide has moved -This folder contains the manual deployment entry point and preparation instructions for the Sovereign Cloud MicroHack's Azure Arc Jumpstart LocalBox environment. +Choose [hosted-event setup](../hosted-events/readme.md) or [manual setup](../manual-setup/readme.md), then complete the common [LocalBox preparation](../localbox/readme.md). -## Overview +The manual deployment script is now [manual-setup/localbox/deploy-localbox.ps1](../manual-setup/localbox/deploy-localbox.ps1). The Console continues to use the existing labautomation hook. -For Challenge 6 (Operating a Sovereign Hybrid Cloud with Azure Arc & Azure Local), we use the official Azure Arc Jumpstart LocalBox environment. Participants provision their own Azure Local VMs and use those VMs for the Defender for Cloud and Azure Update Manager exercises. This provides: +## Step 6: Test the Environment -- **Maintained templates** - Arc Jumpstart is actively maintained by Microsoft -- **Latest features** - Always uses the newest Azure Arc and Azure Local capabilities -- **Validated configurations** - Tested and proven deployment patterns -- **Comprehensive documentation** - Extensive guides available - -## LocalBox -Azure Local environment simulating an on-premises private cloud. - -**Features:** -- Virtualized Azure Local cluster -- Arc Resource Bridge integration -- VM deployment capabilities via Azure Portal -- AKS on Azure Local support - -**Requirements:** -- 32 vCPUs (Standard_E32s_v6 recommended) -- ~4-6 hours deployment time - -**Deployment:** -```powershell -.\deploy-localbox.ps1 -ResourceGroupName "rg-localbox-shared" -Location "swedencentral" -``` - -**Cost** - -LocalBox cost is approximately 100-110 USD per day. We recommend setting it up the week before the event, so for example 5 days before the event would result in a cost between 5-600 USD. - -Budget separately for the approved Defender for Servers plan and review its scope and charges before enabling it on the shared subscription. - -## Arc Jumpstart Resources - -- **LocalBox Documentation**: https://jumpstart.azure.com/azure_jumpstart_localbox -- **GitHub Repository**: https://github.com/microsoft/azure_arc - -## Usage Instructions - -### Step 1: Verify Prerequisites -Before deploying, ensure you have: -1. Sufficient vCPU quotas (run `../subscription-preparations/2-vcpu-quotas.ps1`) -2. Required resource providers registered (run `../subscription-preparations/1-resource-providers.ps1`) -3. Azure CLI and Az PowerShell modules installed - -### Step 2: Deploy Environment -Run the LocalBox deployment script: - -```powershell -.\deploy-localbox.ps1 -ResourceGroupName "rg-localbox-shared" -Location "swedencentral" -``` - -### Step 3: Wait for Deployment -LocalBox provisioning can take 4-6 hours, with additional time for the VM image and preparation steps below. Monitor progress in: -- Azure Portal > Resource Groups > Deployments - -#### Hackathons console deployment behavior - -The Hackathons console runs `labautomation/shared-deploy-lab.ps1` once per -subscription. The shared hook creates or reuses `rg-localbox-shared`, validates the -LocalBox template, and submits the deployment asynchronously. Step 2 is -considered successful when Azure accepts that deployment; it does not wait for -the full LocalBox environment to finish provisioning. - -The LocalBox host, network, Bastion, and Log Analytics resources use the selected -lab region. Azure Local registration and its staging storage account use Australia East, -which is a supported Azure Local registration region and is not blocked by the hosted -lab subscription policy that denies West Europe. - -The shared hook applies `SecurityControl=Ignore` to `rg-localbox-shared`, and the -outer LocalBox template applies the same tag to its resources. This temporary MCAPS -exemption is required during Azure Local deployment because cluster validation uses -storage account key authentication and requires public access to its validation -storage account and Key Vault. The exemption is scoped to the LocalBox resource -group and expires after 14 days under MCAPS governance automation. Deploy or rerun -the shared hook early enough for the exemption to take effect before validating the -Azure Local cluster. - -If MCAPS policy modified resources before the exemption took effect, applying the -tag does not revert those existing settings. After the exemption is active, enable -storage account key access and public network access on the validation storage -account, enable public network access on the validation Key Vault, and rerun the -Azure Local validate and deploy workflow from `LocalBox-Client`. - -After Step 2 completes, a facilitator must verify the deployment manually: - -1. Open `rg-localbox-shared` in the Azure Portal and select **Deployments**. -2. Confirm that the `localbox-*` deployment is progressing without a failed - operation. -3. Confirm that the `LocalBox-Client` VM appears after approximately 15-20 - minutes. Its presence confirms that the deployment has started, not that the - complete LocalBox environment is ready. -4. Before signing in to the VM, use **Help > Reset password** on - `LocalBox-Client` to set a facilitator-known password for the `arcdemo` - account. The unattended shared deployment uses an automatically generated - password and does not publish it to participants. -5. Continue monitoring the deployment and complete the storage, VM image, - logical network, and role-assignment checks below before participants start - Challenge 6. Full provisioning can take 4-6 hours. - -Re-running Step 2 does not submit another LocalBox deployment while an existing -`localbox-*` deployment is active or has succeeded. If the existing deployment -failed, correct the Azure deployment error before retrying Step 2. - -### Step 4: Expand UserStorage Volumes - -> [!IMPORTANT] -> LocalBox VM creation may fail for some attendees if the UserStorage volumes do not have enough free space. The default volume sizes (~679 GB each) can be too small when multiple attendees create VMs simultaneously. To fix this, add a 1 TB data disk to each Azure Local node and expand the storage pool before the event. - -![Create role assignment](./img/localbox_storage_01.png) - -This is a two-part process: first add disks from the **host VM**, then expand volumes from a **cluster node**. - -#### Part A: Add data disks to each node (run on the host VM) - -Connect to the LocalBox host VM (`LOCALBOX-CLIENT`) via Azure Bastion or Remote Desktop, open a PowerShell session as Administrator, and run: - -```powershell -# Create and attach a 1 TB dynamic VHDX to each Azure Local node -foreach ($node in @("AzLHOST1", "AzLHOST2")) { - $vhdxPath = "V:\VMs\${node}-S2D_Disk7.vhdx" - Write-Host "Creating $vhdxPath (1 TB dynamic)..." - New-VHD -Path $vhdxPath -SizeBytes 1TB -Dynamic - Add-VMHardDiskDrive -VMName $node -Path $vhdxPath - Write-Host "Attached $vhdxPath to $node" -} -``` - -#### Part B: Expand the storage pool and volumes (run on a cluster node) - -Open **Hyper-V Manager** on the host VM, connect to one of the Azure Local nodes (e.g., `AzLHOST1`), and open a PowerShell session as Administrator on the node: - -**1. Expand the UserStorage virtual disks and partitions:** - -```powershell -# Get the updated pool -$pool = Get-StoragePool -FriendlyName "SU1_Pool" - -# Calculate available space and split evenly between the two UserStorage volumes -$freeSpace = ($pool.Size - $pool.AllocatedSize) -$expandPerDisk = [math]::Floor($freeSpace / 2) - -# Expand each UserStorage_1 virtual disk -foreach ($diskName in @("UserStorage_1")) { - $vdisk = Get-VirtualDisk -FriendlyName $diskName - $currentSize = $vdisk.Size - $newSize = $currentSize + $expandPerDisk - Write-Host "Expanding $diskName from $([math]::Round($currentSize/1GB)) GB to $([math]::Round($newSize/1GB)) GB..." - Resize-VirtualDisk -FriendlyName $diskName -Size $newSize -} - -# Expand the partition to use the new virtual disk space -foreach ($diskName in @("UserStorage_1")) { - $volume = Get-Volume -FriendlyName $diskName - $partition = $volume | Get-Partition - $maxSize = ($partition | Get-PartitionSupportedSize).SizeMax - Resize-Partition -InputObject $partition -Size $maxSize - Write-Host "$diskName partition resized to $([math]::Round($maxSize/1GB)) GB" -} - -# Remove the UserStorage_2 virtual disk as we will only use UserStorage_1 for the labs -Remove-VirtualDisk -FriendlyName UserStorage_2 -``` - -**2. Verify the new sizes:** - -```powershell -Get-StoragePool -FriendlyName "SU1_Pool" -``` - -```powershell -Get-Volume -FriendlyName UserStorage_* -``` - -![Azure Local storage](./img/localbox_storage_01.png) - -You should also see updated values in the Azure Portal: - -![Azure Local storage](./img/localbox_storage_02.png) - -### Step 5: Configure the Environment - -#### VM image and storage paths - -1. In the Azure Portal, navigate to your **Azure Local** instance -2. Select **VM images** in the left menu -3. Click **+ Add VM Image -> From Azure Marketplace** to start the VM image creation wizard -![Create VM image](./img/add_vm_image_win_01.jpg) -4. For the **Image to download** parameter, select **[smalldisk] Windows Server 2025: Azure edition - Gen2** -![Create VM image](./img/add_vm_image_win_02.png) -5. For the **Storage path** parameter, select **Choose manually** and the **UserStorage1** storage path. -6. Select **Review + create** and wait for the deployment to finish (on LocalBox, this takes approximately 2,5 hours due to use of nested VMs and a virtual router VM). -7. To avoid an issue with image file copies when many VMs are created in parallel, delete the storage path **UserStorage2** -![Create VM image](./img/add_vm_image_win_03.png) - -#### Logical network - -1. In the Azure Portal, navigate to your **Azure Local** instance -2. Select **Logical network** in the left menu -3. Click **+ Create logical network** to start the creation wizard -![Create logical network](./img/add_logical_network_01.jpg) -4. For the **Logical network name** parameter, enter **localbox-vm-lnet-vlan200** and click **Next: Network Configuration**: -![Create logical network](./img/add_logical_network_02.jpg) -5. Enter the following parameter values: -- **IPv4 address space**: 192.168.200.0 /24 (256 addresses) -- **IP Pools**: 192.168.200.0 - 192.168.200.255 -- **Default gateway**: 192.168.200.1 -- **DNS Servers**: 192.168.1.254 -- **VLAN ID**: 200 -![Create logical network](./img/add_logical_network_03.jpg) -6. Select **Review + create** and wait for the deployment to finish - -Role assignments: -1. In the Azure Portal, navigate to your resource group where LocalBox is deployed (e.g. `rg-localbox-shared`) -2. Select **Access control (IAM)** in the left menu -3. Click **+ Add -> Add role assignment** to start the role assignment wizard -![Create role assignment](./img/add_rbac_01.jpg) -4. Select the **Reader** role and click **Next** -![Create role assignment](./img/add_rbac_02.jpg) -5. Click **+ Select members** to add the **LabUsers** Entra ID group -![Create role assignment](./img/add_rbac_03.jpg) -6. Click **Next** -7. For **Assignment type**, select **Active** and click **Review + assign** -![Create role assignment](./img/add_rbac_04.jpg) -8. Repeat steps 1-7 to add a role assignment for the **Azure Stack HCI VM Contributor** role -![Create role assignment](./img/add_rbac_05.jpg) - -Hosted lab automation grants participants access to shared LocalBox resources, **Owner** on their own resource group, and subscription-level **Security Reader** for Defender visibility. For manual setups, verify equivalent scoped permissions before testing. Students must be able to create their own VM, manage its extensions, and assess updates without managing other participants' VMs. Do not grant subscription-level Owner or User Access Administrator to perform these exercises. - -#### Defender for Servers readiness (organizer) - -1. Confirm the Defender for Servers plan approved for the event, including its subscription scope and charges -2. As an authorized subscription administrator, open **Microsoft Defender for Cloud -> Environment settings** and select the lab subscription -3. Under **Defender plans**, enable **Servers** with the approved plan if it is not already enabled, and save the changes -4. Verify that the [Defender for Endpoint integration](https://learn.microsoft.com/azure/defender-for-cloud/enable-defender-for-endpoint) is enabled and that required onboarding settings and extensions can apply to the participant VMs -5. Verify that participants have **Security Reader** access to review coverage and recommendations. Students verify protection on their own VM; subscription-level plan changes remain an organizer responsibility - -### Step 6: Test the Environment - -Before participants begin, follow the [Challenge 6 walkthrough](../../walkthrough/challenge-06/solution-06.md) using a normal participant identity: - -1. Create a Windows Server 2025 VM in that participant's assigned resource group on the prepared LocalBox logical network, with **Enable guest management** selected -2. Verify that the VM has a valid IP address, working DNS, and outbound access to the required Azure Arc, Defender, and configured Windows update-source endpoints. Confirm **Guest management: Enabled (Connected)** on the VM's **Overview -> Properties -> Configuration** page; see [Enable guest management](https://learn.microsoft.com/azure/azure-local/manage/manage-arc-virtual-machines#enable-guest-management) if onboarding fails -3. Confirm that the same VM appears in Defender for Cloud **Inventory**, verify Defender for Servers coverage and onboarding, and review its assessment status. Allow time for recommendations to populate; an empty list is not proof of completed assessment -4. Locate that VM in Azure Update Manager **Resources -> Machines**, run **Check for updates**, and verify a successful assessment and its timestamp. Zero pending updates is a valid result; a pending or failed assessment is not -5. Confirm that neither exercise requires selecting shared cluster nodes, the LocalBox host, Arc Resource Bridge, or another participant's VM, or changing subscription-level settings - -Resolve connectivity, extension provisioning, or permission failures before the event. Keep any permission changes limited to the operation and resource scope actually required. Do not install patches or restart shared resources during this readiness test. - -## 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 -- **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 +Run the [Pester health checks](../tests/readme.md), then complete the [participant VM guest-management, Defender and Update Manager readiness exercise](../localbox/manual-preparation.md#step-6-test-the-environment). This page preserves existing challenge links; challenge and solution content has not changed. \ 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 new file mode 100644 index 000000000..186ca6df1 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md @@ -0,0 +1,52 @@ +# Hosted-event runbook + +This runbook covers the FY27 EMEA Cloud & AI Hackathons program described in the Event Lead Guide. Confirm eligibility and access with your program lead; the Console is not a general entitlement for all Microsoft events or 1:1 customer engagements. Other delivery uses [manual setup](../manual-setup/readme.md). + +## Configure the event + +1. Agree the Sovereign Cloud scenario with your GEO/program lead and request an Event Lead account through the program's access process. +2. Sign in to the [Hacks Console](https://emea.microhack.cloud) with the supplied account using Entra sign-in. Complete MFA/password setup. Run the [prerequisites check](https://emea.microhack.cloud/prerequisites). +3. Create a **New Hackathon** for each event iteration and select the Sovereign Cloud content pack. Use `Country-Hack type-Date-Use case` for live events and `Test-Country-Short title` for tests. +4. Set the event time zone, start slightly before attendees arrive and end with an appropriate buffer. Enable the event. Participant access is limited to its configured window. +5. Set automatic provisioning at least **24 hours before** a Sovereign event. LocalBox preparation includes a multi-hour image download. An accepted deployment is not a ready environment. +6. Set teams and participants per team before provisioning. **Teams equal labs and the lab count is locked once provisioning starts.** Add late registrants to existing teams instead. +7. Preview content and review the live estimate, including shared LocalBox costs and licensing. The guide's Sovereign budget is **USD 2,000**; obtain program-lead approval for exceptions. Normal validation uses **exactly one lab**, not a load test. Content Leads own scale testing. + +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. + +## Complete LocalBox preparation + +The shared hook submits `localbox-*` asynchronously in `rg-localbox-shared`. 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. + +1. Inspect the Azure deployment, Client bootstrap logs and Azure Local/Arc status. Resolve failures before proceeding. +2. Connect to `LocalBox-Client` through its Bastion. If needed, an authorized facilitator uses **Help > Reset password** to set a known password for the `arcdemo` host account. Resetting the host password does not change the nested-node Windows credentials. +3. Obtain the AKS admin-group object ID and 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. Azure authentication uses the Client VM's managed identity; nested Windows storage operations require their own credentials. +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. Complete the [participant VM readiness exercise](../localbox/manual-preparation.md#step-6-test-the-environment), including guest management, Defender and Update Manager. Subscription-wide paid-plan changes require the authorized owner and approved budget. + +### Policy failures + +The hosted wrapper applies a temporary, resource-group-scoped `SecurityControl=Ignore` exemption for Azure Local validation. It expires under hosted governance automation after 14 days. Its tag alone does not prove that policy processing is complete, and it does not undo earlier policy changes. Ask the content/platform owner to verify the exemption and repair the validation storage/Key Vault settings if provisioning was already affected. The preparation script does not disable policy, change those security settings or renew exemptions. + +### Automatic shutdown + +Deployment automation also applies `CostControl=Ignore` to the LocalBox resource group and its deployed resources, and to the participant lab resource groups, resources and Azure AKS node pools. This requests exemption from hosted cost-control shutdown automation so LocalBox and the other lab VMs remain running during preparation and delivery. Resource-group tags are not inherited, so the resources are tagged explicitly. Re-running shared preparation also merges the tag onto an existing `LocalBox-Client` VM when reusing its deployment; participant resources receive it on their next deployment. + +Verify that the hosted governance system honors the tag. It does not disable independent VM shutdown schedules, restart already stopped VMs or prevent the Console's scheduled teardown. Keep monitoring costs and retain the event end time and cleanup process. + +### Console group dependency + +The [platform contract](../../../../../99-MicroHack-Template/labautomation/README.md#groups-supported-values) currently documents `groups` as licensing activation (`GHCPUsers`, `M365-E5-Users`), not arbitrary group creation. Do not add an invented group name or schema field. + +The Console owner must provide the supported mechanism to create/reuse a security group per shared LocalBox scope, maintain intended temporary Azure users/coaches and late registrations, expose its object ID, and remove the group at teardown. If the supported hook returns the ID, it can publish a non-reserved `LocalBox AKS Admin Group ID` credential. Until that contract is available, obtain the ID through the Console owner. The preparation script prompts for it but does not create groups, query Graph or grant directory privileges. + +## Run and close the event + +- Export Console user credentials and distribute them securely to the correct attendees/coaches. Console login credentials and Azure Temporary Access Passes (TAPs) are distinct. +- Ensure the event is **LIVE**, then select **Start Hack**. Timers are optional; coaches validate work and approve challenge advancement. +- For schedule changes, save the new times and select **Update TAPs** to refresh existing Azure access. +- Access is revoked at the scheduled end; the guide describes environment teardown approximately 15-30 minutes later. Verify completion, including the shared LocalBox resource group and the Console-owned group. Escalate retained resources instead of launching the broad manual cleanup utility. +- For forced cleanup, use both **Delete & Purge Users** and **Remove Lab Environment** in the Console lifecycle. **Retain the event record** for cost reporting. + +For support, use the current program contacts/playbook supplied with Console access. Console issues go to its owner; content deployment failures go to the Sovereign Cloud content owners. \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_logical_network_01.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_logical_network_01.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_logical_network_01.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_logical_network_01.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_logical_network_02.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_logical_network_02.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_logical_network_02.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_logical_network_02.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_logical_network_03.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_logical_network_03.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_logical_network_03.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_logical_network_03.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_01.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_01.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_01.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_01.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_02.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_02.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_02.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_02.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_03.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_03.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_03.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_03.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_04.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_04.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_04.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_04.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_05.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_05.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_rbac_05.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_rbac_05.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_vm_image_win_01.jpg b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_vm_image_win_01.jpg similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_vm_image_win_01.jpg rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_vm_image_win_01.jpg diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_vm_image_win_02.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_vm_image_win_02.png similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_vm_image_win_02.png rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_vm_image_win_02.png diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_vm_image_win_03.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_vm_image_win_03.png similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/add_vm_image_win_03.png rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/add_vm_image_win_03.png diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/localbox_storage_01.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/localbox_storage_01.png similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/localbox_storage_01.png rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/localbox_storage_01.png diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/localbox_storage_02.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/localbox_storage_02.png similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/img/localbox_storage_02.png rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/localbox_storage_02.png 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 new file mode 100644 index 000000000..a1eb3308a --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md @@ -0,0 +1,107 @@ +# LocalBox manual preparation and participant validation + +Start with [shared preparation](readme.md). This reference retains portal screenshots and the participant smoke test for troubleshooting or manually prepared environments. Deployment instructions are in [manual setup](../manual-setup/readme.md); Console timing, permissions and policy recovery are in [hosted events](../hosted-events/readme.md). + +Do not run manual provisioning alongside the preparation script. Inspect existing state before modifying any resource, and never remove shared storage containing workloads. + +### Step 4: Expand UserStorage Volumes + +> [!IMPORTANT] +> LocalBox VM creation may fail for some attendees if the UserStorage volumes do not have enough free space. The default volume sizes (~679 GB each) can be too small when multiple attendees create VMs simultaneously. To fix this, add a 1 TB data disk to each Azure Local node and expand the storage pool before the event. + +![Create role assignment](./img/localbox_storage_01.png) + +Prefer the guarded storage phase in [prepare-localbox.ps1](../prepare-localbox.ps1). It adds stable-named disks, checks capacity and grows to a fixed target. Nested Windows credentials are required separately from Azure authentication. If it refuses a resize or consolidation, ask an Azure Local administrator to review the capacity, resiliency and workload placement before proceeding. + +From an elevated session on a nested cluster node, inspect storage without modifying it: + +```powershell +Get-StoragePool -FriendlyName "SU1_Pool" +``` + +```powershell +Get-Volume | Where-Object FileSystemLabel -Like 'UserStorage_*' +``` + +![Azure Local storage](./img/localbox_storage_01.png) + +You should also see updated values in the Azure Portal: + +![Azure Local storage](./img/localbox_storage_02.png) + +### Step 5: Configure the Environment + +#### VM image and storage paths + +1. In the Azure Portal, navigate to your **Azure Local** instance +2. Select **VM images** in the left menu +3. Click **+ Add VM Image -> From Azure Marketplace** to start the VM image creation wizard +![Create VM image](./img/add_vm_image_win_01.jpg) +4. For the **Image to download** parameter, select **[smalldisk] Windows Server 2025: Azure edition - Gen2** +![Create VM image](./img/add_vm_image_win_02.png) +5. For the **Storage path** parameter, select **Choose manually** and the **UserStorage1** storage path. +6. Select **Review + create** and wait for the deployment to finish (on LocalBox, this takes approximately 2,5 hours due to use of nested VMs and a virtual router VM). +7. To avoid image-placement issues in parallel VM creation, review whether **UserStorage2** can be removed while the environment is still empty. Do not delete a storage path or virtual disk that contains workloads; use the script's explicit consolidation checks before importing the image. +![Create VM image](./img/add_vm_image_win_03.png) + +#### Logical network + +1. In the Azure Portal, navigate to your **Azure Local** instance +2. Select **Logical network** in the left menu +3. Click **+ Create logical network** to start the creation wizard +![Create logical network](./img/add_logical_network_01.jpg) +4. For the **Logical network name** parameter, enter **localbox-vm-lnet-vlan200** and click **Next: Network Configuration**: +![Create logical network](./img/add_logical_network_02.jpg) +5. Enter the following parameter values: +- **IPv4 address space**: 192.168.200.0 /24 (256 addresses) +- **IP Pools**: 192.168.200.10 - 192.168.200.199, only after verifying DHCP exclusions and infrastructure reservations; never include the network, broadcast or gateway addresses +- **Default gateway**: 192.168.200.1 +- **DNS Servers**: 192.168.1.254 +- **VLAN ID**: 200 +![Create logical network](./img/add_logical_network_03.jpg) +6. Select **Review + create** and wait for the deployment to finish + +For the separate AKS network and workload cluster, use [shared preparation](readme.md) and the [Jumpstart AKS guide](https://jumpstart.azure.com/azure_jumpstart_localbox/AKS). Do not place AKS nodes in the VM network or reuse its address pool. + +Role assignments: +1. In the Azure Portal, navigate to your resource group where LocalBox is deployed (e.g. `rg-localbox-shared`) +2. Select **Access control (IAM)** in the left menu +3. Click **+ Add -> Add role assignment** to start the role assignment wizard +![Create role assignment](./img/add_rbac_01.jpg) +4. Select the **Reader** role and click **Next** +![Create role assignment](./img/add_rbac_02.jpg) +5. Click **+ Select members** to add the **LabUsers** Entra ID group +![Create role assignment](./img/add_rbac_03.jpg) +6. Click **Next** +7. For **Assignment type**, select **Active** and click **Review + assign** +![Create role assignment](./img/add_rbac_04.jpg) +8. Repeat steps 1-7 to add a role assignment for the **Azure Stack HCI VM Contributor** role +![Create role assignment](./img/add_rbac_05.jpg) + +Hosted lab automation grants participants access to shared LocalBox resources, **Owner** on their own resource group, and subscription-level **Security Reader** for Defender visibility. For manual setups, verify equivalent scoped permissions before testing. Students must be able to create their own VM, manage its extensions, and assess updates without managing other participants' VMs. Do not grant subscription-level Owner or User Access Administrator to perform these exercises. + +#### Defender for Servers readiness (organizer) + +1. Confirm the Defender for Servers plan approved for the event, including its subscription scope and charges +2. As an authorized subscription administrator, open **Microsoft Defender for Cloud -> Environment settings** and select the lab subscription +3. Under **Defender plans**, enable **Servers** with the approved plan if it is not already enabled, and save the changes +4. Verify that the [Defender for Endpoint integration](https://learn.microsoft.com/azure/defender-for-cloud/enable-defender-for-endpoint) is enabled and that required onboarding settings and extensions can apply to the participant VMs +5. Verify that participants have **Security Reader** access to review coverage and recommendations. Students verify protection on their own VM; subscription-level plan changes remain an organizer responsibility + +### Step 6: Test the Environment + +Before participants begin, follow the [Challenge 6 walkthrough](../../walkthrough/challenge-06/solution-06.md) using a normal participant identity: + +1. Create a Windows Server 2025 VM in that participant's assigned resource group on the prepared LocalBox logical network, with **Enable guest management** selected +2. Verify that the VM has a valid IP address, working DNS, and outbound access to the required Azure Arc, Defender, and configured Windows update-source endpoints. Confirm **Guest management: Enabled (Connected)** on the VM's **Overview -> Properties -> Configuration** page; see [Enable guest management](https://learn.microsoft.com/azure/azure-local/manage/manage-arc-virtual-machines#enable-guest-management) if onboarding fails +3. Confirm that the same VM appears in Defender for Cloud **Inventory**, verify Defender for Servers coverage and onboarding, and review its assessment status. Allow time for recommendations to populate; an empty list is not proof of completed assessment +4. Locate that VM in Azure Update Manager **Resources -> Machines**, run **Check for updates**, and verify a successful assessment and its timestamp. Zero pending updates is a valid result; a pending or failed assessment is not +5. Confirm that neither exercise requires selecting shared cluster nodes, the LocalBox host, Arc Resource Bridge, or another participant's VM, or changing subscription-level settings + +Resolve connectivity, extension provisioning, or permission failures before the event. Keep any permission changes limited to the operation and resource scope actually required. Do not install patches or restart shared resources during this readiness test. + +## 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 +- **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 new file mode 100644 index 000000000..cc01ef2a2 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/readme.md @@ -0,0 +1,67 @@ +# LocalBox post-provisioning preparation + +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. + +## Prerequisites + +- Elevated PowerShell 7 on `LocalBox-Client`; installed Azure CLI with `stack-hci-vm`, `customlocation` and `aksarc` extensions. Install missing extensions explicitly before running; the script neither installs nor upgrades tooling. Record the versions used for your event. +- Healthy Azure Local nodes, storage, Arc Resource Bridge, `jumpstart` custom location and `hybridaksextension`. +- The Client VM's system-assigned managed identity and its existing LocalBox resource-group permissions. The script uses a temporary isolated CLI login and restores the caller's profile. No subscription-wide or Graph permissions are added. +- An existing Entra AKS admin-group object ID. Hosted events obtain this from the Console owner; [group provisioning is a documented dependency](../hosted-events/readme.md#console-group-dependency). Group membership is not validated through Graph by this script. +- Windows administrator credentials for `AzLHOST1`/`AzLHOST2`. Enter these in the local `Get-Credential` prompt. Azure managed identity cannot authenticate Windows PowerShell Direct inside nested nodes. +- `Microsoft.EdgeMarketplace` registered by the subscription owner. The Marketplace image workflow also requires the Azure Connected Machine Resource Manager role for the `Microsoft.AzureStackHCI` resource-provider identity on the image resource group; ask the deployment owner to verify this prerequisite. No automatic privilege escalation is attempted. +- Verified DHCP exclusions/static reservations for both pools, sufficient backing `V:` capacity and event budget. Dynamic disks do not add physical backing capacity. + +## Download and run + +Download files, inspect them, then execute. Do not pipe downloaded content into `Invoke-Expression`. The main URL works after publication; use your published branch/ref while validating a change. An immutable commit is preferable for repeatable events. + +```powershell +$base = 'https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources' +Invoke-WebRequest "$base/prepare-localbox.ps1" -OutFile './prepare-localbox.ps1' +$nodeCredential = Get-Credential -Message 'Nested Azure Local Windows administrator' +./prepare-localbox.ps1 -NodeCredential $nodeCredential -AddressReservationsConfirmed -WhatIf +./prepare-localbox.ps1 -NodeCredential $nodeCredential -AddressReservationsConfirmed +``` + +The script prompts for the Entra group object ID, or accepts `-AksAdminGroupObjectId`. Subscription, resource group and configuration path default from Jumpstart's machine environment. Explicit scope values must match this Client's deployment. Run only one preparation session at a time. + +`-WhatIf` performs authenticated reads and local validation, but no Azure/Hyper-V/storage provisioning. It can create and remove a temporary local CLI profile. It does not establish that later resource creation will succeed. + +## Prepared resources + +| Resource | Default | +| --- | --- | +| Storage | One stable-named 1 TiB dynamic VHDX per node; grow `UserStorage_1` to 1 TiB, never shrink or grow it again on every rerun | +| VM image | `localbox-windows-server-2025`, Marketplace Windows Server 2025 Azure Edition smalldisk, explicitly on `UserStorage1` | +| VM network | `localbox-vm-lnet-vlan200`, VLAN 200, `192.168.200.0/24`, pool `.10-.199` after reservation review | +| AKS network | `localbox-aks-lnet-vlan110`, VLAN 110, `10.10.0.0/24`, nodes `.101-.199` | +| AKS control plane / service reservation | `10.10.0.5`; `.10-.100` reserved for future service VIPs; this script does not install a load balancer | +| AKS cluster | `localbox-aks`, one control-plane and one Linux worker; `Standard_A4_v2` defaults, subject to installed-version capacity/CLI validation | +| Output | `C:\LocalBox\sovereign-localbox.json`, nonsecret IDs and expected configuration for health tests | + +Topology defaults come from the installed Jumpstart config, except the VM pool bounds and configurable resource names/sizes. The network/broadcast addresses, gateways and Jumpstart infrastructure reservations must not be allocated. `-AddressReservationsConfirmed` is your explicit confirmation that DHCP/static reservations were reviewed; the script cannot safely infer all DHCP leases from Azure. It does not change routers or DHCP configuration. + +Use `-ImageVersion` to pin a Marketplace version, `-KubernetesVersion` for a supported AKS version, and size/count overrides when required. Images can take several hours; `-TimeoutMinutes` defaults to 360 per long operation. A timed-out local command does not cancel an already submitted Azure operation. Inspect status before rerunning. Matching resources are reused; incompatible resources produce an error, never automatic replacement. + +## Optional storage consolidation + +On a new, empty environment, pass `-RemoveUserStorage2` to request removal of the unused second Azure storage path, CSV and virtual disk. Each destructive operation requires confirmation. The script refuses consolidation when storage is unhealthy/busy, the volume is not empty, its identity is ambiguous, or existing LocalBox workload resources are found. Do this **before** image/AKS creation. Never use confirmation suppression to override a failed safety check. + +If existing workloads use the secondary volume, do not remove it. Use the [manual reference](manual-preparation.md) with an Azure Local administrator to assess storage and placement. Image placement is explicitly `UserStorage1`; retaining `UserStorage2` can still affect other workload placement and requires the participant VM smoke test. + +## Verify readiness + +Follow [Pester health checks](../tests/readme.md). Provisioning success is not a health verdict. The nonsecret manifest intentionally records `FullHealthVerified = false`. + +For AKS runtime access, follow [Jumpstart's AKS guide](https://jumpstart.azure.com/azure_jumpstart_localbox/AKS): use a separate CLI profile/session authenticated as a group member, and keep `az connectedk8s proxy` running with a dedicated kubeconfig. Do not log out the provisioning identity or give its managed identity broader permissions to run tests. Group membership grants Kubernetes administration; Azure proxy/connect permissions must also be in place. + +Finally, use the [participant VM exercise](manual-preparation.md#step-6-test-the-environment) to validate actual VM guest networking, guest management, Defender coverage and update assessment. These participant-created VMs are not created by the preparation script. + +## References + +- [Jumpstart AKS on Azure Local](https://jumpstart.azure.com/azure_jumpstart_localbox/AKS) +- [Azure Local Marketplace images](https://learn.microsoft.com/azure/azure-local/manage/virtual-machine-image-azure-marketplace) +- [Logical networks for AKS on Azure Local](https://learn.microsoft.com/azure/aks-hybrid-edge/local/hyperconverged/aks-networks) +- [Azure CLI image commands](https://learn.microsoft.com/cli/azure/stack-hci-vm/image) +- [Jumpstart configuration](https://github.com/microsoft/azure_arc/blob/main/azure_jumpstart_localbox/artifacts/PowerShell/LocalBox-Config.psd1) \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/cleanup/1-resource-groups.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/cleanup/1-resource-groups.ps1 similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/cleanup/1-resource-groups.ps1 rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/cleanup/1-resource-groups.ps1 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/deploy-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/localbox/deploy-localbox.ps1 similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/demo-vm-creator/deploy-localbox.ps1 rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/localbox/deploy-localbox.ps1 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/readme.md new file mode 100644 index 000000000..b1735a8ff --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/readme.md @@ -0,0 +1,33 @@ +# Manual setup + +Use this path outside eligible Console-hosted events. You own the Azure subscription, costs, permissions and cleanup. A personal subscription does not need Console accounts, exported users or a `LabUsers` group. + +## Prepare the subscription + +Install PowerShell 7, Azure CLI and the Az modules required by each script. Authenticate interactively as an authorized subscription owner. For multi-user workshops, review the scripts before running them: + +1. [Register resource providers](subscription-preparations/1-resource-providers.ps1). This existing utility processes all subscriptions visible to its Azure PowerShell account; restrict account access/context appropriately before use. +2. [Check quotas](subscription-preparations/2-vcpu-quotas.ps1), including confidential VM capacity. LocalBox additionally needs 32 available Esv5/Esv6 vCPUs in its selected host region. +3. [Assign multi-user RBAC](subscription-preparations/3-rbac.ps1) and [create participant resource groups](subscription-preparations/4-resource-groups.ps1) only when needed. Review Graph permissions and target group/scope. Do not give students subscription Owner just to operate LocalBox. + +## Deploy participant infrastructure + +The [Sovereign lab Bicep template](../../labautomation/sovereign-lab.bicep) provisions the Azure AKS cluster and confidential node pool, confidential VM, K3s VM, attestation, Bastion and networking used across Challenges 4, 5 and 7. Deploy it into your chosen resource group with your selected region, a unique `nameSuffix`, and secure `adminPassword`/`cvmAdminPassword` inputs using Azure's deployment tooling. Do not put passwords in source control or shell history. Retain the successful deployment name and outputs for health testing. + +For organizers reproducing the full Console workflow locally, follow the [template's local testing instructions](../../../../../99-MicroHack-Template/labautomation/README.md#local-testing), including its helper container and shared preparation before per-lab deployment. The Console hooks are not general standalone deployment scripts. + +## Deploy and prepare LocalBox + +Run the [manual LocalBox entry point](localbox/deploy-localbox.ps1) from an authenticated organizer machine: + +```powershell +./localbox/deploy-localbox.ps1 -ResourceGroupName 'rg-localbox-shared' -Location 'swedencentral' +``` + +Choose an allowed Azure Local registration region with `-AzureLocalInstanceLocation` when needed. It can differ from the Azure host region. Wait for the nested Azure Local deployment, then connect to `LocalBox-Client` and follow the shared [post-provisioning guide](../localbox/readme.md). Supply an existing Entra security-group object ID for AKS; its intended administrators must be members. + +Run the [health checks](../tests/readme.md) before participants begin. Verify scoped participant access and approved Defender for Servers settings using the [manual preparation and readiness reference](../localbox/manual-preparation.md). + +## Cleanup + +There is no automatic Console teardown in this path. Remove only resource groups and groups you own after use, including shared LocalBox, and verify removal. The [existing bulk cleanup utility](cleanup/1-resource-groups.ps1) scans all accessible subscriptions and deletes every resource group matching its pattern after confirmation. It is intended for controlled multi-user lab subscriptions, not ordinary personal subscriptions or hosted-event lifecycle cleanup. Preview its complete list and never use a broad pattern against unrelated workloads. \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/1-resource-providers.ps1 similarity index 99% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/1-resource-providers.ps1 index 8ad55552e..94b321443 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/1-resource-providers.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/1-resource-providers.ps1 @@ -79,6 +79,7 @@ $providers = @( # Azure Local (Stack HCI) "Microsoft.AzureStackHCI", + "Microsoft.EdgeMarketplace", "Microsoft.ResourceConnector", "Microsoft.HybridContainerService", diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/2-vcpu-quotas.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/2-vcpu-quotas.ps1 similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/2-vcpu-quotas.ps1 rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/2-vcpu-quotas.ps1 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/3-rbac.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/3-rbac.ps1 similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/3-rbac.ps1 rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/3-rbac.ps1 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/4-resource-groups.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/4-resource-groups.ps1 similarity index 100% rename from 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/subscription-preparations/4-resource-groups.ps1 rename to 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/manual-setup/subscription-preparations/4-resource-groups.ps1 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 new file mode 100644 index 000000000..a5ef06248 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 @@ -0,0 +1,475 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Prepares an already deployed Jumpstart LocalBox for the Sovereign Cloud MicroHack. +.DESCRIPTION +Run elevated on LocalBox-Client. Azure operations use its managed identity; +nested Windows operations use a separately supplied PSCredential. +Dot-source this file to load its functions without running provisioning. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$SubscriptionId = $env:subscriptionId, + [string]$ResourceGroupName = $env:resourceGroup, + [string]$ConfigPath = $env:LocalBoxConfigFile, + [string]$AksAdminGroupObjectId, + [pscredential]$NodeCredential, + [string]$VmSwitchName, + [string]$ImageName = 'localbox-windows-server-2025', + [string]$ImageVersion = 'latest', + [string]$VmPoolStart = '192.168.200.10', + [string]$VmPoolEnd = '192.168.200.199', + [string]$AksClusterName = 'localbox-aks', + [string]$KubernetesVersion, + [string]$NodeVmSize = 'Standard_A4_v2', + [string]$ControlPlaneVmSize = 'Standard_A4_v2', + [ValidateRange(1, 10)][int]$NodeCount = 1, + [ValidateRange(512, 4096)][int]$StorageSizeGB = 1024, + [switch]$RemoveUserStorage2, + [switch]$AddressReservationsConfirmed, + [ValidateRange(1, 720)][int]$TimeoutMinutes = 360, + [string]$ManifestPath = 'C:\LocalBox\sovereign-localbox.json' +) + +function ConvertTo-LocalBoxIPv4Number { + param([Parameter(Mandatory)][string]$Address) + $parsed = [System.Net.IPAddress]::Parse($Address) + if ($parsed.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + throw "Expected IPv4: $Address" + } + $bytes = $parsed.GetAddressBytes() + return [uint64]$bytes[0] * 16777216 + [uint64]$bytes[1] * 65536 + [uint64]$bytes[2] * 256 + $bytes[3] +} + +function Test-LocalBoxAddressPool { + param( + [Parameter(Mandatory)][string]$Prefix, + [Parameter(Mandatory)][string]$Start, + [Parameter(Mandatory)][string]$End, + [Parameter(Mandatory)][string]$Gateway, + [string[]]$Reserved = @(), + [hashtable[]]$ReservedRanges = @() + ) + $parts = $Prefix.Split('/') + if ($parts.Count -ne 2 -or $parts[1] -notmatch '^([1-9]|[12][0-9]|30)$') { + throw "Invalid IPv4 subnet: $Prefix" + } + $size = [uint64][math]::Pow(2, 32 - [int]$parts[1]) + $network = ConvertTo-LocalBoxIPv4Number $parts[0] + if ($network % $size -ne 0) { throw "Subnet must use its network address: $Prefix" } + $broadcast = $network + $size - 1 + $first = ConvertTo-LocalBoxIPv4Number $Start + $last = ConvertTo-LocalBoxIPv4Number $End + $gatewayNumber = ConvertTo-LocalBoxIPv4Number $Gateway + if ($first -le $network -or $last -ge $broadcast -or $first -gt $last) { + throw "Pool $Start-$End must contain only usable addresses within $Prefix." + } + if ($gatewayNumber -le $network -or $gatewayNumber -ge $broadcast) { + throw "Gateway $Gateway is outside the usable subnet." + } + foreach ($address in @($Gateway) + $Reserved) { + $number = ConvertTo-LocalBoxIPv4Number $address + if ($number -ge $first -and $number -le $last) { throw "Pool includes reserved address $address." } + } + foreach ($range in $ReservedRanges) { + $rangeStart = ConvertTo-LocalBoxIPv4Number $range.Start + $rangeEnd = ConvertTo-LocalBoxIPv4Number $range.End + if ($rangeStart -gt $rangeEnd) { throw 'Invalid reserved range.' } + if ($first -le $rangeEnd -and $last -ge $rangeStart) { throw "Pool overlaps reserved range $($range.Start)-$($range.End)." } + } +} + +function Assert-LocalBoxGroupId { + param([Parameter(Mandatory)][string]$Value) + $identifier = [guid]::Empty + if (-not [guid]::TryParse($Value, [ref]$identifier) -or $identifier -eq [guid]::Empty) { + throw 'Supply a nonempty Entra security-group object ID, not a name or application ID.' + } + return $identifier.ToString() +} + +function Invoke-LocalBoxAz { + param([Parameter(Mandatory)][string[]]$Arguments, [int]$TimeoutSeconds = 300) + $job = Start-Job -ArgumentList (, $Arguments) -ScriptBlock { + param($CommandArguments) + $output = & az @CommandArguments --only-show-errors --output json 2>&1 + if ($LASTEXITCODE -ne 0) { throw ($output -join "`n") } + $text = $output -join "`n" + if ($text.Trim()) { $text | ConvertFrom-Json -AsHashtable -ErrorAction Stop } + } + try { + if (-not (Wait-Job $job -Timeout $TimeoutSeconds)) { + throw "Azure CLI timed out. A submitted Azure operation may still be running; inspect it before retrying." + } + Receive-Job $job -ErrorAction Stop + } + finally { Remove-Job $job -Force -ErrorAction SilentlyContinue } +} + +function Get-LocalBoxResource { + param([string]$Id) + Invoke-LocalBoxAz @('resource', 'show', '--ids', $Id) +} + +function Wait-LocalBoxResource { + param([string]$Id, [int]$TimeoutSeconds = 3600) + $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + $resource = Get-LocalBoxResource $Id + $state = $resource.properties.provisioningState + if ($state -eq 'Succeeded') { return $resource } + if ($state -in @('Failed', 'Canceled', 'Deleting')) { throw "$Id is $state. Inspect resource deployment errors; no resources were rolled back." } + if ([datetime]::UtcNow -ge $deadline) { throw "Timed out waiting for $Id (state: $state)." } + Write-Host "Waiting for $Id ($state)..." + Start-Sleep -Seconds 15 + } while ($true) +} + +function Assert-LocalBoxProperties { + param($Actual, $Expected, [string]$Path = 'resource') + if ($Expected -is [System.Collections.IDictionary]) { + foreach ($key in $Expected.Keys) { + if ($null -eq $Actual -or -not $Actual.Contains($key)) { throw "Missing $Path.$key" } + Assert-LocalBoxProperties $Actual[$key] $Expected[$key] "$Path.$key" + } + } + elseif ($Expected -is [array]) { + if (@($Actual).Count -ne $Expected.Count) { throw "Conflicting array at $Path" } + for ($index = 0; $index -lt $Expected.Count; $index++) { + Assert-LocalBoxProperties @($Actual)[$index] $Expected[$index] "$Path[$index]" + } + } + elseif ([string]$Actual -ine [string]$Expected) { + throw "Conflicting $Path. Expected '$Expected', found '$Actual'. Existing resources are not replaced." + } +} + +function Sync-LocalBoxResource { + [CmdletBinding(SupportsShouldProcess)] + param([string]$Id, [hashtable]$Expected, [string[]]$CreateArguments, [int]$TimeoutSeconds = 21600) + $parts = $Id.Split('/') + $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--subscription', $parts[2], '--resource-group', $parts[4])) + $existing = @($resources | Where-Object { $_.id -ieq $Id }) + if ($existing.Count -gt 0) { + $resource = Wait-LocalBoxResource $Id -TimeoutSeconds $TimeoutSeconds + Assert-LocalBoxProperties $resource $Expected + Write-Host "Reusing $Id" + return $resource + } + if ($PSCmdlet.ShouldProcess($Id, 'Create resource')) { + Invoke-LocalBoxAz $CreateArguments -TimeoutSeconds $TimeoutSeconds | Out-Null + $resource = Wait-LocalBoxResource $Id -TimeoutSeconds $TimeoutSeconds + Assert-LocalBoxProperties $resource $Expected + return $resource + } +} + +function Get-LocalBoxNodeState { + param([string]$Node, [pscredential]$Credential) + Invoke-Command -VMName $Node -Credential $Credential -ErrorAction Stop -ScriptBlock { + $ErrorActionPreference = 'Stop' + $pool = Get-StoragePool -FriendlyName 'SU1_Pool' + $disk = Get-VirtualDisk -FriendlyName 'UserStorage_1' + $volume = Get-Volume -FileSystemLabel 'UserStorage_1' + $secondary = @(Get-VirtualDisk | Where-Object FriendlyName -eq 'UserStorage_2') + $secondaryVolume = @(Get-Volume | Where-Object FileSystemLabel -eq 'UserStorage_2') + $secondaryCsv = @(Get-ClusterSharedVolume | Where-Object { $_.SharedVolumeInfo.Partition.Name -in $secondaryVolume.Path }) + $files = @() + if ($secondary.Count) { + if ($secondaryVolume.Count -ne 1 -or $secondaryCsv.Count -ne 1) { throw 'Cannot uniquely resolve UserStorage_2 volume and CSV. Removal is unsafe.' } + $files = @(Get-ChildItem -LiteralPath $secondaryCsv[0].SharedVolumeInfo.FriendlyVolumeName -Force | + Where-Object Name -ne 'System Volume Information') + } + @{ + Switches = @(Get-VMSwitch -SwitchType External | Select-Object -ExpandProperty Name) + PoolHealth = [string]$pool.HealthStatus + DiskHealth = [string]$disk.HealthStatus + VolumeHealth = [string]$volume.HealthStatus + Size = $disk.Size + Free = $volume.SizeRemaining + PoolFree = $pool.Size - $pool.AllocatedSize + Copies = $disk.NumberOfDataCopies + SecondaryPresent = $secondary.Count -gt 0 + SecondaryFiles = @($files | Select-Object -ExpandProperty Name) + SecondaryCsv = if ($secondaryCsv.Count) { $secondaryCsv[0].Name } else { '' } + NodesUp = @(Get-ClusterNode | Where-Object State -ne 'Up').Count -eq 0 + StorageJobs = @(Get-StorageJob).Count + UnhealthyPhysicalDisks = @(Get-PhysicalDisk -StoragePool $pool | Where-Object HealthStatus -ne 'Healthy').Count + } + } +} + +function Assert-LocalBoxStorageRemoval { + param($State, [array]$Resources) + if (-not $State.NodesUp -or $State.PoolHealth -ne 'Healthy' -or $State.StorageJobs -ne 0 -or $State.UnhealthyPhysicalDisks -ne 0) { + throw 'Storage/cluster is not healthy and idle; refusing removal.' + } + if (@($State.SecondaryFiles).Count) { throw 'UserStorage_2 is not empty. No files or workloads will be removed.' } + $workloads = @($Resources | Where-Object { + $_.type -in @('Microsoft.AzureStackHCI/virtualMachineInstances', 'Microsoft.AzureStackHCI/virtualMachines', + 'Microsoft.AzureStackHCI/virtualHardDisks', 'Microsoft.AzureStackHCI/galleryImages', + 'Microsoft.AzureStackHCI/marketplaceGalleryImages', 'Microsoft.Kubernetes/connectedClusters') + }) + if ($workloads.Count) { throw 'Existing LocalBox workloads/images/disks found. Review and consolidate storage manually before provisioning.' } +} + +function Initialize-LocalBoxStorage { + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + param( + [hashtable]$Configuration, [pscredential]$Credential, [int]$DesiredSizeGB, + [string]$Subscription, [string]$ResourceGroup, [switch]$RemoveSecondary + ) + $nodes = @($Configuration.NodeHostConfig.Hostname) + $drive = Get-Volume -DriveLetter $Configuration.HostVMDriveLetter -ErrorAction Stop + if ($drive.HealthStatus -ne 'Healthy' -or $drive.SizeRemaining -lt 200GB) { + throw 'The backing host volume must be healthy with at least 200 GiB free. Dynamic VHDX capacity is not physical capacity.' + } + foreach ($node in $nodes) { + $path = Join-Path $Configuration.HostVMPath "$node-microhack-s2d.vhdx" + if (Test-Path -LiteralPath $path) { + $disk = Get-VHD -Path $path -ErrorAction Stop + if ($disk.Size -ne 1TB -or $disk.VhdType -ne 'Dynamic') { throw "Unexpected existing disk: $path" } + } + elseif ($PSCmdlet.ShouldProcess($path, 'Create 1 TiB dynamic nested data disk')) { + New-VHD -Path $path -SizeBytes 1TB -Dynamic -ErrorAction Stop | Out-Null + } + $attached = @(Get-VMHardDiskDrive -VMName $node -ErrorAction Stop | Where-Object Path -eq $path) + if (-not $attached.Count -and $PSCmdlet.ShouldProcess($node, "Attach $path")) { + Add-VMHardDiskDrive -VMName $node -Path $path -ErrorAction Stop + } + } + $state = Get-LocalBoxNodeState $nodes[0] $Credential + if ($state.PoolHealth -ne 'Healthy' -or $state.DiskHealth -ne 'Healthy' -or -not $state.NodesUp -or $state.StorageJobs -or $state.UnhealthyPhysicalDisks) { + throw 'Storage is not healthy/idle. Allow disk discovery and storage jobs to finish, then rerun.' + } + if ($RemoveSecondary) { + $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--subscription', $Subscription, '--resource-group', $ResourceGroup)) + $path = @($resources | Where-Object { $_.type -ieq 'Microsoft.AzureStackHCI/storagecontainers' -and $_.name -ieq 'UserStorage2' }) + if ($state.SecondaryPresent -or $path.Count) { + Assert-LocalBoxStorageRemoval $state $resources + if ($PSCmdlet.ShouldProcess('UserStorage2 / UserStorage_2', 'Permanently remove EMPTY storage path, CSV and virtual disk')) { + if ($path.Count) { Invoke-LocalBoxAz @('resource', 'delete', '--ids', $path[0].id) | Out-Null } + if ($state.SecondaryPresent) { + Invoke-Command -VMName $nodes[0] -Credential $Credential -ErrorAction Stop -ScriptBlock { + $ErrorActionPreference = 'Stop' + $volume = Get-Volume -FileSystemLabel 'UserStorage_2' + $csv = @(Get-ClusterSharedVolume | Where-Object { $_.SharedVolumeInfo.Partition.Name -eq $volume.Path }) + if ($csv.Count -ne 1) { throw 'Cannot uniquely resolve secondary CSV.' } + $content = @(Get-ChildItem -LiteralPath $csv[0].SharedVolumeInfo.FriendlyVolumeName -Force | + Where-Object Name -ne 'System Volume Information') + if ($content.Count) { throw 'Secondary volume is no longer empty; stopping.' } + Remove-ClusterSharedVolume -Name $csv[0].Name -ErrorAction Stop + Remove-VirtualDisk -FriendlyName 'UserStorage_2' -Confirm:$false -ErrorAction Stop + } + } + } + } + } + if ($state.Size -lt ([uint64]$DesiredSizeGB * 1GB) -and $PSCmdlet.ShouldProcess('UserStorage_1', "Grow to $DesiredSizeGB GiB")) { + Invoke-Command -VMName $nodes[0] -Credential $Credential -ArgumentList $DesiredSizeGB -ErrorAction Stop -ScriptBlock { + param($TargetGB) + $ErrorActionPreference = 'Stop' + $pool = Get-StoragePool -FriendlyName 'SU1_Pool' + $disk = Get-VirtualDisk -FriendlyName 'UserStorage_1' + $target = [uint64]$TargetGB * 1GB + $supported = Get-VirtualDiskSupportedSize -StoragePoolFriendlyName 'SU1_Pool' -ResiliencySettingName $disk.ResiliencySettingName + $growth = $target - $disk.Size + if ($disk.NumberOfDataCopies -lt 1 -or $growth -gt $supported.VirtualDiskSizeMax -or + ($growth * $disk.NumberOfDataCopies + 100GB) -gt ($pool.Size - $pool.AllocatedSize)) { + throw 'Insufficient supported storage capacity including resiliency and reserve. No resize performed.' + } + Resize-VirtualDisk -FriendlyName 'UserStorage_1' -Size $target + } + } + if ($PSCmdlet.ShouldProcess('UserStorage_1 partition', 'Extend partition to existing virtual disk size')) { + Invoke-Command -VMName $nodes[0] -Credential $Credential -ErrorAction Stop -ScriptBlock { + $ErrorActionPreference = 'Stop' + $partition = Get-Volume -FileSystemLabel 'UserStorage_1' | Get-Partition + $maximum = ($partition | Get-PartitionSupportedSize).SizeMax + if ($maximum -gt $partition.Size) { $partition | Resize-Partition -Size $maximum } + } + } +} + +function Invoke-LocalBoxPreparation { + [CmdletBinding(SupportsShouldProcess)] + param([hashtable]$Settings) + $ErrorActionPreference = 'Stop' + if (-not $IsWindows -or $env:COMPUTERNAME -ine 'LocalBox-Client') { throw 'Run on the Windows LocalBox-Client VM.' } + $principal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Open PowerShell 7 as Administrator.' } + foreach ($command in @('az', 'Get-VM', 'Get-VHD', 'Invoke-Command')) { Get-Command $command -ErrorAction Stop | Out-Null } + $config = Import-PowerShellDataFile -LiteralPath $Settings.ConfigPath + foreach ($required in @('SubscriptionId', 'ResourceGroupName')) { + if (-not $Settings[$required]) { throw "$required must be supplied or available in the LocalBox environment." } + } + if ($Settings.SubscriptionId -ine $env:subscriptionId -or $Settings.ResourceGroupName -ine $env:resourceGroup) { + throw 'Requested Azure scope does not match this LocalBox deployment.' + } + $groupId = $Settings.AksAdminGroupObjectId + while (-not $groupId) { + $candidate = Read-Host 'Entra AKS admin-group object ID (provided by Console; group must already exist)' + try { $groupId = Assert-LocalBoxGroupId $candidate } catch { Write-Warning $_.Exception.Message } + } + $groupId = Assert-LocalBoxGroupId $groupId + Test-LocalBoxAddressPool $config.vmIpPrefix $Settings.VmPoolStart $Settings.VmPoolEnd $config.vmGateway @($config.dcVLAN200IP) + $vipRange = @{ Start = $config.AKSVIPStartIP; End = $config.AKSVIPEndIP } + Test-LocalBoxAddressPool $config.AKSIPPrefix $config.AKSNodeStartIP $config.AKSNodeEndIP $config.AKSGWIP @($config.AKSControlPlaneIP) -ReservedRanges @($vipRange) + Test-LocalBoxAddressPool $config.AKSIPPrefix $config.AKSControlPlaneIP $config.AKSControlPlaneIP $config.AKSGWIP -ReservedRanges @($vipRange) + if ($config.vmVLAN -eq $config.AKSVLAN -or $config.vmIpPrefix -eq $config.AKSIPPrefix) { throw 'VMs and AKS must use separate VLANs and subnets.' } + if (-not $Settings.AddressReservationsConfirmed) { + throw 'Verify BOTH pools against DHCP leases/exclusions and static reservations, then pass -AddressReservationsConfirmed. The script does not alter DHCP/router configuration.' + } + if (-not $Settings.NodeCredential) { $Settings.NodeCredential = Get-Credential -Message 'Windows administrator of the nested Azure Local nodes (not the Azure identity)' } + $previousConfig = $env:AZURE_CONFIG_DIR + $previousExtensions = $env:AZURE_EXTENSION_DIR + $previousDynamicInstall = $env:AZURE_EXTENSION_USE_DYNAMIC_INSTALL + $profile = Join-Path ([IO.Path]::GetTempPath()) "microhack-az-$([guid]::NewGuid())" + $lock = [Threading.Mutex]::new($false, 'Global\MicroHackLocalBoxPreparation') + $locked = $false + try { + $locked = $lock.WaitOne(0) + if (-not $locked) { throw 'Another LocalBox preparation is running.' } + if (-not $previousExtensions) { + $base = if ($previousConfig) { $previousConfig } else { Join-Path $HOME '.azure' } + $env:AZURE_EXTENSION_DIR = Join-Path $base 'cliextensions' + } + $env:AZURE_CONFIG_DIR = $profile + $env:AZURE_EXTENSION_USE_DYNAMIC_INSTALL = 'no' + Invoke-LocalBoxAz @('login', '--identity') | Out-Null + Invoke-LocalBoxAz @('account', 'set', '--subscription', $Settings.SubscriptionId) | Out-Null + foreach ($extension in @('stack-hci-vm', 'customlocation', 'aksarc')) { + $version = Invoke-LocalBoxAz @('extension', 'show', '--name', $extension) + Write-Host "$extension $($version.version)" + } + $scope = "/subscriptions/$($Settings.SubscriptionId)/resourceGroups/$($Settings.ResourceGroupName)" + $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--resource-group', $Settings.ResourceGroupName)) + $cluster = @($resources | Where-Object { $_.type -ieq 'Microsoft.AzureStackHCI/clusters' -and $_.name -eq $config.ClusterName }) + if ($cluster.Count -ne 1) { throw 'Azure Local cluster not found; complete Jumpstart provisioning first.' } + $clusterState = Get-LocalBoxResource $cluster[0].id + if ($clusterState.properties.provisioningState -ne 'Succeeded' -or $clusterState.properties.connectivityStatus -ne 'Connected') { throw 'Azure Local is not provisioned and connected.' } + $custom = Get-LocalBoxResource "$scope/providers/Microsoft.ExtendedLocation/customLocations/$($config.rbCustomLocationName)" + if ($custom.properties.provisioningState -ne 'Succeeded') { throw 'Custom location is not ready.' } + $bridges = @($resources | Where-Object type -ieq 'Microsoft.ResourceConnector/appliances') + if ($bridges.Count -ne 1) { throw 'Expected one Arc Resource Bridge.' } + $bridge = Get-LocalBoxResource $bridges[0].id + if ($bridge.properties.provisioningState -ne 'Succeeded' -or $bridge.properties.status -ne 'Running') { throw 'Arc Resource Bridge is not running.' } + $extensions = @($custom.properties.clusterExtensionIds | Where-Object { $_ -match '/hybridaksextension$' }) + if ($extensions.Count -ne 1 -or (Get-LocalBoxResource $extensions[0]).properties.provisioningState -ne 'Succeeded') { throw 'hybridaksextension is not ready.' } + foreach ($node in $config.NodeHostConfig.Hostname) { + if ((Get-VM -Name $node).State -ne 'Running') { throw "Nested node $node is not running." } + $state = Get-LocalBoxNodeState $node $Settings.NodeCredential + if (-not $state.NodesUp -or $state.PoolHealth -ne 'Healthy') { throw "Nested cluster/storage is not healthy on $node." } + if (-not $Settings.VmSwitchName) { + if ($state.Switches.Count -ne 1) { throw 'Specify -VmSwitchName when there is more than one external switch.' } + $Settings.VmSwitchName = $state.Switches[0] + } + if ($Settings.VmSwitchName -notin $state.Switches) { throw "External switch not present on $node." } + } + $provider = Invoke-LocalBoxAz @('provider', 'show', '--namespace', 'Microsoft.EdgeMarketplace') + if ($provider.registrationState -ne 'Registered') { throw 'Ask the subscription administrator to register Microsoft.EdgeMarketplace before importing the image.' } + Initialize-LocalBoxStorage -Configuration $config -Credential $Settings.NodeCredential -DesiredSizeGB $Settings.StorageSizeGB ` + -Subscription $Settings.SubscriptionId -ResourceGroup $Settings.ResourceGroupName -RemoveSecondary:$Settings.RemoveUserStorage2 + $state = Get-LocalBoxNodeState $config.NodeHostConfig[0].Hostname $Settings.NodeCredential + if (-not $WhatIfPreference -and ($state.Size -lt ($Settings.StorageSizeGB * 1GB) -or $state.Free -lt 100GB)) { throw 'UserStorage_1 is too small or has less than 100 GiB free.' } + $storageId = "$scope/providers/Microsoft.AzureStackHCI/storageContainers/UserStorage1" + $storage = Get-LocalBoxResource $storageId + if ($storage.properties.provisioningState -ne 'Succeeded') { throw 'UserStorage1 Azure storage path is not ready.' } + $location = $custom.location + $timeout = $Settings.TimeoutMinutes * 60 + $common = @('--subscription', $Settings.SubscriptionId, '--resource-group', $Settings.ResourceGroupName, '--location', $location, '--custom-location', $custom.id) + $imageId = "$scope/providers/Microsoft.AzureStackHCI/marketplaceGalleryImages/$($Settings.ImageName)" + $imageExpected = @{ + extendedLocation = @{ name = $custom.id }; location = $location + properties = @{ containerId = $storageId; osType = 'Windows'; identifier = @{ + publisher = 'microsoftwindowsserver'; offer = 'windowsserver'; sku = '2025-datacenter-azure-edition-smalldisk' + } } + } + if ($Settings.ImageVersion -ne 'latest') { $imageExpected.properties.version = @{ name = $Settings.ImageVersion } } + $image = Sync-LocalBoxResource -Id $imageId -Expected $imageExpected -TimeoutSeconds $timeout -CreateArguments (@('stack-hci-vm', 'image', 'create') + $common + @( + '--name', $Settings.ImageName, '--os-type', 'Windows', '--publisher', 'microsoftwindowsserver', '--offer', 'windowsserver', + '--sku', '2025-datacenter-azure-edition-smalldisk', '--version', $Settings.ImageVersion, '--storage-path-id', $storageId)) + $networks = @( + @{ Name = 'localbox-vm-lnet-vlan200'; Prefix = $config.vmIpPrefix; Gateway = $config.vmGateway; Dns = $config.vmDNS; Vlan = $config.vmVLAN; Start = $Settings.VmPoolStart; End = $Settings.VmPoolEnd }, + @{ Name = 'localbox-aks-lnet-vlan110'; Prefix = $config.AKSIPPrefix; Gateway = $config.AKSGWIP; Dns = $config.AKSDNSIP; Vlan = $config.AKSVLAN; Start = $config.AKSNodeStartIP; End = $config.AKSNodeEndIP } + ) + foreach ($network in $networks) { + $network.Id = "$scope/providers/Microsoft.AzureStackHCI/logicalNetworks/$($network.Name)" + $network.Expected = @{ + extendedLocation = @{ name = $custom.id }; location = $location + properties = @{ vmSwitchName = $Settings.VmSwitchName; dhcpOptions = @{ dnsServers = @($network.Dns) }; subnets = @(@{ + properties = @{ addressPrefix = $network.Prefix; ipAllocationMethod = 'Static'; vlan = [int]$network.Vlan + ipPools = @(@{ start = $network.Start; end = $network.End }); routeTable = @{ properties = @{ routes = @(@{ properties = @{ addressPrefix = '0.0.0.0/0'; nextHopIpAddress = $network.Gateway } }) } } + } + }) } + } + Sync-LocalBoxResource -Id $network.Id -Expected $network.Expected -TimeoutSeconds $timeout -CreateArguments (@('stack-hci-vm', 'network', 'lnet', 'create') + $common + @( + '--name', $network.Name, '--vm-switch-name', $Settings.VmSwitchName, '--ip-allocation-method', 'Static', '--address-prefixes', $network.Prefix, + '--gateway', $network.Gateway, '--dns-servers', $network.Dns, '--vlan', $network.Vlan, '--ip-pool-start', $network.Start, '--ip-pool-end', $network.End)) | Out-Null + } + $aksId = "$scope/providers/Microsoft.Kubernetes/connectedClusters/$($Settings.AksClusterName)" + $instanceId = "$aksId/providers/Microsoft.HybridContainerService/provisionedClusterInstances/default" + $aksExpected = @{ extendedLocation = @{ name = $custom.id }; properties = @{ + controlPlane = @{ count = 1; vmSize = $Settings.ControlPlaneVmSize; controlPlaneEndpoint = @{ hostIP = $config.AKSControlPlaneIP } } + cloudProviderProfile = @{ infraNetworkProfile = @{ vnetSubnetIds = @($networks[1].Id) } } + agentPoolProfiles = @(@{ count = $Settings.NodeCount; vmSize = $Settings.NodeVmSize }) + } } + if ($Settings.KubernetesVersion) { $aksExpected.properties.kubernetesVersion = $Settings.KubernetesVersion } + $existingAks = @((Invoke-LocalBoxAz @('resource', 'list', '--resource-group', $Settings.ResourceGroupName)) | Where-Object id -ieq $aksId) + if (-not $existingAks.Count) { + $arguments = @('aksarc', 'create') + $common + @('--name', $Settings.AksClusterName, '--vnet-ids', $networks[1].Id, + '--aad-admin-group-object-ids', $groupId, '--generate-ssh-keys', '--control-plane-ip', $config.AKSControlPlaneIP, + '--node-count', [string]$Settings.NodeCount, '--node-vm-size', $Settings.NodeVmSize, '--control-plane-count', '1', '--control-plane-vm-size', $Settings.ControlPlaneVmSize) + if ($Settings.KubernetesVersion) { $arguments += @('--kubernetes-version', $Settings.KubernetesVersion) } + if ($PSCmdlet.ShouldProcess($aksId, 'Validate and create AKS on Azure Local')) { + Invoke-LocalBoxAz ($arguments + @('--validate')) -TimeoutSeconds $timeout | Out-Null + Invoke-LocalBoxAz $arguments -TimeoutSeconds $timeout | Out-Null + } + } + if (-not $WhatIfPreference) { + $instance = Wait-LocalBoxResource $instanceId -TimeoutSeconds $timeout + Assert-LocalBoxProperties $instance $aksExpected + $connected = Wait-LocalBoxResource $aksId -TimeoutSeconds $timeout + Assert-LocalBoxProperties $connected @{ properties = @{ aadProfile = @{ adminGroupObjectIDs = @($groupId) } } } + $deadline = [datetime]::UtcNow.AddSeconds($timeout) + while ($connected.properties.connectivityStatus -ne 'Connected') { + if ([datetime]::UtcNow -ge $deadline) { throw 'AKS exists but Azure Arc is not connected.' } + Start-Sleep -Seconds 15 + $connected = Get-LocalBoxResource $aksId + } + } + $manifest = @{ + SubscriptionId = $Settings.SubscriptionId; ResourceGroupName = $Settings.ResourceGroupName; CustomLocationId = $custom.id + ClusterId = $cluster[0].id; BridgeId = $bridge.id; ExtensionId = $extensions[0]; StorageId = $storageId + ImageId = $imageId; ImageExpected = $imageExpected; ImageVersion = $image.properties.version.name + Networks = $networks; AksId = $aksId; AksInstanceId = $instanceId; AksExpected = $aksExpected; AksAdminGroupObjectId = $groupId + NodeNames = @($config.NodeHostConfig.Hostname); StorageSizeGB = $Settings.StorageSizeGB; PreparedAt = [datetime]::UtcNow.ToString('o') + FullHealthVerified = $false + } + if ($PSCmdlet.ShouldProcess($Settings.ManifestPath, 'Write nonsecret preparation manifest')) { + $manifest | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Settings.ManifestPath -Encoding utf8 + } + Write-Host 'Preparation finished. Run test-sovereign-cloud.ps1 before declaring the environment ready.' + } + finally { + $env:AZURE_CONFIG_DIR = $previousConfig + $env:AZURE_EXTENSION_DIR = $previousExtensions + $env:AZURE_EXTENSION_USE_DYNAMIC_INSTALL = $previousDynamicInstall + if (Test-Path -LiteralPath $profile) { Remove-Item -LiteralPath $profile -Recurse -Force -WhatIf:$false } + if ($locked) { $lock.ReleaseMutex() } + $lock.Dispose() + } +} + +if ($MyInvocation.InvocationName -ne '.') { + $settings = @{ + SubscriptionId = $SubscriptionId; ResourceGroupName = $ResourceGroupName; ConfigPath = $ConfigPath + AksAdminGroupObjectId = $AksAdminGroupObjectId; NodeCredential = $NodeCredential; VmSwitchName = $VmSwitchName + ImageName = $ImageName; ImageVersion = $ImageVersion; VmPoolStart = $VmPoolStart; VmPoolEnd = $VmPoolEnd + AksClusterName = $AksClusterName; KubernetesVersion = $KubernetesVersion; NodeVmSize = $NodeVmSize + ControlPlaneVmSize = $ControlPlaneVmSize; NodeCount = $NodeCount; StorageSizeGB = $StorageSizeGB + RemoveUserStorage2 = [bool]$RemoveUserStorage2; AddressReservationsConfirmed = [bool]$AddressReservationsConfirmed + TimeoutMinutes = $TimeoutMinutes; ManifestPath = $ManifestPath + } + Invoke-LocalBoxPreparation -Settings $settings +} \ No newline at end of file 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 new file mode 100644 index 000000000..3ee396a08 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/readme.md @@ -0,0 +1,24 @@ +# Sovereign Cloud infrastructure preparation + +Choose the delivery path before running any setup or cleanup scripts. + +| Delivery path | Start here | Infrastructure ownership | +| --- | --- | --- | +| Eligible Microsoft-hosted event | [Hosted events](hosted-events/readme.md) | Hacks Console provisions lab scopes, identities and baseline infrastructure. Event Leads complete LocalBox preparation and validate readiness. | +| Personal learning, customer-led or other non-Console delivery | [Manual setup](manual-setup/readme.md) | The subscription owner provisions, funds and removes the infrastructure. | + +Both paths use [LocalBox preparation](localbox/readme.md) and the [Pester health checks](tests/readme.md). The Console integration remains in [labautomation](../labautomation); do not run those platform hooks as standalone scripts without the documented platform helper environment. + +## Responsibility boundary + +| Task | Hosted event | Manual delivery | +| --- | --- | --- | +| Subscriptions, users, initial lab RBAC, provider/quota preparation | Console and content automation | Subscription owner | +| Participant Azure AKS, confidential VM, K3s, attestation and networking | Console content automation | Subscription owner using the lab template | +| Shared LocalBox deployment | Console submits once per 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 dependency; obtain the object ID from the Console owner | 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 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 new file mode 100644 index 000000000..abc725acd --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 @@ -0,0 +1,152 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Runs Pester health checks against explicitly selected Sovereign Cloud lab scopes. +.DESCRIPTION +Uses the caller's Azure CLI login. Never changes authentication or provisions resources. +Full checks require kubeconfigs and, for participant guest probes, explicit consent +to invoke read-only commands using Azure VM Run Command. See tests/readme.md. +.EXAMPLE +./test-sovereign-cloud.ps1 -Scope LocalBox -LocalBoxManifestPath ./sovereign-localbox.json -Mode ControlPlane +.EXAMPLE +./test-sovereign-cloud.ps1 -InventoryPath ./inventory.json -AllowGuestRunCommand +#> +[CmdletBinding()] +param( + [ValidateSet('LocalBox', 'ParticipantLabs', 'All')][string]$Scope = 'All', + [ValidateSet('ControlPlane', 'Full')][string]$Mode = 'Full', + [string]$InventoryPath, + [string]$LocalBoxManifestPath, + [string]$LocalBoxKubeconfig, + [pscredential]$NodeCredential, + [switch]$AllowGuestRunCommand, + [ValidateRange(1, 120)][int]$TimeoutMinutes = 15, + [string]$OutputDirectory = './health-results', + [string]$GitHubRef = 'main', + [switch]$DownloadTests +) + +function Invoke-SovereignKubectl { + param([string]$Kubeconfig, [string[]]$Arguments) + if (-not $Kubeconfig -or -not (Test-Path -LiteralPath $Kubeconfig)) { throw 'A valid, independently authenticated kubeconfig is required for Full checks.' } + $output = & kubectl --kubeconfig $Kubeconfig --request-timeout=20s @Arguments -o json 2>&1 + if ($LASTEXITCODE -ne 0) { throw "kubectl failed: $($output -join ' ')" } + ($output -join "`n") | ConvertFrom-Json -AsHashtable -ErrorAction Stop +} + +function Assert-SovereignNodes { + param($Nodes, [int]$MinimumCount) + if (@($Nodes.items).Count -lt $MinimumCount) { throw "Expected at least $MinimumCount Kubernetes nodes." } + foreach ($node in $Nodes.items) { + $ready = @($node.status.conditions | Where-Object { $_.type -eq 'Ready' -and $_.status -eq 'True' }) + if ($ready.Count -ne 1) { throw "Node $($node.metadata.name) is not Ready." } + } +} + +function Assert-SovereignSystemPods { + param($Pods) + $systemPods = @($Pods.items | Where-Object { $_.metadata.namespace -in @('kube-system', 'azure-arc', 'aks-istio-system') }) + if (-not $systemPods.Count) { throw 'No system pods returned; health cannot be established.' } + foreach ($pod in $systemPods) { + if ($pod.status.phase -eq 'Succeeded') { continue } + $ready = @($pod.status.conditions | Where-Object { $_.type -eq 'Ready' -and $_.status -eq 'True' }) + if ($pod.status.phase -ne 'Running' -or $ready.Count -ne 1) { throw "System pod $($pod.metadata.namespace)/$($pod.metadata.name) is not healthy." } + foreach ($container in @($pod.status.containerStatuses)) { + if (-not $container.ready -or $container.state.waiting) { throw "Container $($container.name) is not ready." } + } + } +} + +function Test-SovereignKubernetes { + param([string]$Kubeconfig, [int]$MinimumNodes) + Assert-SovereignNodes (Invoke-SovereignKubectl $Kubeconfig @('get', 'nodes')) $MinimumNodes + Assert-SovereignSystemPods (Invoke-SovereignKubectl $Kubeconfig @('get', 'pods', '-A')) + $controllers = Invoke-SovereignKubectl $Kubeconfig @('get', 'deployments,daemonsets', '-n', 'kube-system') + if (-not @($controllers.items).Count) { throw 'No kube-system controllers found.' } + foreach ($controller in $controllers.items) { + if ($controller.kind -eq 'DaemonSet') { + if ($controller.status.desiredNumberScheduled -lt 1 -or $controller.status.numberReady -lt $controller.status.desiredNumberScheduled) { throw "DaemonSet $($controller.metadata.name) is unavailable." } + } + elseif ($controller.spec.replicas -gt 0 -and $controller.status.availableReplicas -lt $controller.spec.replicas) { + throw "Deployment $($controller.metadata.name) is unavailable." + } + } +} + +function Wait-SovereignCheck { + param([scriptblock]$Check, [int]$TimeoutSeconds = 900) + $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + try { & $Check; return } + catch { + if ($_.Exception.Message -match 'AuthorizationFailed|Forbidden|Unauthorized|InvalidAuthentication|not logged in|independently authenticated|provisioning failed') { throw } + if ([datetime]::UtcNow -ge $deadline) { throw } + Start-Sleep -Seconds 15 + } + } while ($true) +} + +function Get-SovereignLabOutputs { + param([hashtable]$Lab) + if ($Lab.Outputs) { return $Lab.Outputs } + if (-not $Lab.DeploymentName) { throw 'Each lab requires DeploymentName or explicit Outputs; resource enumeration is not an expected inventory.' } + $deployment = Invoke-LocalBoxAz @('deployment', 'group', 'show', '--subscription', $Lab.SubscriptionId, + '--resource-group', $Lab.ResourceGroupName, '--name', $Lab.DeploymentName) + if ($deployment.properties.provisioningState -ne 'Succeeded') { throw 'The selected lab deployment did not succeed.' } + $outputs = @{} + foreach ($key in $deployment.properties.outputs.Keys) { $outputs[$key] = $deployment.properties.outputs[$key].value } + return $outputs +} + +function Get-SovereignReadiness { + param($Result, [string]$Mode) + return $Mode -eq 'Full' -and $Result.TotalCount -gt 0 -and $Result.FailedCount -eq 0 -and + $Result.SkippedCount -eq 0 -and $Result.NotRunCount -eq 0 -and $Result.PassedCount -eq $Result.TotalCount +} + +if ($MyInvocation.InvocationName -ne '.') { + $ErrorActionPreference = 'Stop' + $root = $PSScriptRoot + if ($DownloadTests) { + $root = Join-Path ([IO.Path]::GetTempPath()) "microhack-health-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path "$root/tests" -Force | Out-Null + $base = "https://raw.githubusercontent.com/microsoft/MicroHack/$GitHubRef/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources" + foreach ($file in @('prepare-localbox.ps1', 'test-sovereign-cloud.ps1', 'tests/localbox.health.tests.ps1', 'tests/sovereign-lab.health.tests.ps1')) { + Invoke-WebRequest -Uri "$base/$file" -OutFile (Join-Path $root $file) + } + } + Import-Module Pester -MinimumVersion 5.7.1 -MaximumVersion 5.999.999 -ErrorAction Stop + $inventory = if ($InventoryPath) { Get-Content -LiteralPath $InventoryPath -Raw | ConvertFrom-Json -AsHashtable } else { @{} } + if ($LocalBoxManifestPath) { $inventory.LocalBox = Get-Content -LiteralPath $LocalBoxManifestPath -Raw | ConvertFrom-Json -AsHashtable } + if ($LocalBoxKubeconfig) { $inventory.LocalBox.Kubeconfig = $LocalBoxKubeconfig } + $containers = @() + $data = @{ Mode = $Mode; TimeoutSeconds = $TimeoutMinutes * 60; ResourceRoot = $root } + if ($Scope -in @('LocalBox', 'All')) { + if (-not $inventory.LocalBox) { throw 'LocalBox scope requires a preparation manifest, or LocalBox in the inventory.' } + $containers += New-PesterContainer -Path "$root/tests/localbox.health.tests.ps1" -Data ($data + @{ LocalBox = $inventory.LocalBox; NodeCredential = $NodeCredential }) + } + if ($Scope -in @('ParticipantLabs', 'All')) { + if (-not $inventory.Labs -or -not @($inventory.Labs).Count) { throw 'ParticipantLabs scope requires a nonempty Labs inventory.' } + foreach ($lab in $inventory.Labs) { + if (-not $lab.SubscriptionId -or -not $lab.ResourceGroupName) { throw 'Every lab must specify SubscriptionId and ResourceGroupName.' } + $containers += New-PesterContainer -Path "$root/tests/sovereign-lab.health.tests.ps1" -Data ($data + @{ Lab = $lab; AllowGuestRunCommand = [bool]$AllowGuestRunCommand }) + } + } + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + $configuration = New-PesterConfiguration + $configuration.Run.Container = $containers + $configuration.Run.PassThru = $true + $configuration.Output.Verbosity = 'Detailed' + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputFormat = 'NUnitXml' + $configuration.TestResult.OutputPath = Join-Path $OutputDirectory 'health.xml' + $result = Invoke-Pester -Configuration $configuration + $ready = Get-SovereignReadiness $result $Mode + @{ + Timestamp = [datetime]::UtcNow.ToString('o'); Scope = $Scope; Mode = $Mode; FullReadiness = $ready + Passed = $result.PassedCount; Failed = $result.FailedCount; Skipped = $result.SkippedCount; NotRun = $result.NotRunCount + Tests = @($result.Tests | ForEach-Object { @{ Name = $_.ExpandedPath; Result = $_.Result } }) + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'health.json') -Encoding utf8 + if ($result.FailedCount -or ($Mode -eq 'Full' -and -not $ready)) { exit 1 } + if ($Mode -eq 'ControlPlane') { Write-Warning 'Control-plane checks passed. Runtime health has NOT been verified.' } +} \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 new file mode 100644 index 000000000..c0993787c --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 @@ -0,0 +1,99 @@ +param([hashtable]$LocalBox, [pscredential]$NodeCredential, [string]$Mode, [int]$TimeoutSeconds, [string]$ResourceRoot) + +BeforeAll { + . "$ResourceRoot/prepare-localbox.ps1" -NodeCredential $NodeCredential + . "$ResourceRoot/test-sovereign-cloud.ps1" -NodeCredential $NodeCredential -Mode $Mode +} + +Describe 'Shared LocalBox control plane' { + It 'has all mandatory manifest fields' { + foreach ($field in @('SubscriptionId', 'ResourceGroupName', 'ClusterId', 'BridgeId', 'ExtensionId', 'CustomLocationId', 'StorageId', 'ImageId', 'AksId', 'AksInstanceId', 'AksAdminGroupObjectId', 'ImageExpected', 'AksExpected', 'NodeNames', 'StorageSizeGB')) { + $LocalBox[$field] | Should -Not -BeNullOrEmpty -Because "$field is part of the expected inventory" + } + @($LocalBox.Networks).Count | Should -Be 2 + } + It 'has a running Client with managed identity and successful bootstrap' { + $hostVm = Invoke-LocalBoxAz @('vm', 'get-instance-view', '--subscription', $LocalBox.SubscriptionId, '--resource-group', $LocalBox.ResourceGroupName, '--name', 'LocalBox-Client') + $hostVm.instanceView.statuses.code | Should -Contain 'PowerState/running' + $hostVm.identity.principalId | Should -Not -BeNullOrEmpty + $bootstrap = Invoke-LocalBoxAz @('vm', 'extension', 'show', '--subscription', $LocalBox.SubscriptionId, '--resource-group', $LocalBox.ResourceGroupName, '--vm-name', 'LocalBox-Client', '--name', 'Bootstrap') + $bootstrap.provisioningState | Should -Be 'Succeeded' + } + It 'has provisioned connected Azure Local and a running Arc bridge' { + $cluster = Wait-LocalBoxResource $LocalBox.ClusterId -TimeoutSeconds $TimeoutSeconds + $cluster.properties.connectivityStatus | Should -Be 'Connected' + $bridge = Wait-LocalBoxResource $LocalBox.BridgeId -TimeoutSeconds $TimeoutSeconds + $bridge.properties.status | Should -Be 'Running' + Wait-LocalBoxResource $LocalBox.CustomLocationId -TimeoutSeconds $TimeoutSeconds | Should -Not -BeNullOrEmpty + Wait-LocalBoxResource $LocalBox.ExtensionId -TimeoutSeconds $TimeoutSeconds | Should -Not -BeNullOrEmpty + } + It 'has a ready image on the expected storage path' { + Wait-LocalBoxResource $LocalBox.StorageId -TimeoutSeconds $TimeoutSeconds | Should -Not -BeNullOrEmpty + $image = Wait-LocalBoxResource $LocalBox.ImageId -TimeoutSeconds $TimeoutSeconds + Assert-LocalBoxProperties $image $LocalBox.ImageExpected + $image.properties.status.progressPercentage | Should -Be 100 + } + It 'has two matching logical networks with valid static pools' { + @($LocalBox.Networks).Count | Should -Be 2 + foreach ($network in $LocalBox.Networks) { + Test-LocalBoxAddressPool $network.Prefix $network.Start $network.End $network.Gateway + $actual = Wait-LocalBoxResource $network.Id -TimeoutSeconds $TimeoutSeconds + Assert-LocalBoxProperties $actual $network.Expected + } + $LocalBox.Networks[0].Vlan | Should -Not -Be $LocalBox.Networks[1].Vlan + $LocalBox.Networks[0].Prefix | Should -Not -Be $LocalBox.Networks[1].Prefix + } + It 'has the expected AKS cluster, admin group and Arc connection' { + $instance = Wait-LocalBoxResource $LocalBox.AksInstanceId -TimeoutSeconds $TimeoutSeconds + Assert-LocalBoxProperties $instance $LocalBox.AksExpected + Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { + $cluster = Get-LocalBoxResource $LocalBox.AksId + $cluster.properties.connectivityStatus | Should -Be 'Connected' + Assert-LocalBoxProperties $cluster @{ properties = @{ aadProfile = @{ adminGroupObjectIDs = @($LocalBox.AksAdminGroupObjectId) } } } + } + } + It 'has healthy supporting Azure resources' { + $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--subscription', $LocalBox.SubscriptionId, '--resource-group', $LocalBox.ResourceGroupName)) + foreach ($type in @('Microsoft.Network/virtualNetworks', 'Microsoft.Network/networkInterfaces', 'Microsoft.Storage/storageAccounts', 'Microsoft.OperationalInsights/workspaces')) { + $selected = @($resources | Where-Object type -ieq $type) + $selected.Count | Should -BeGreaterThan 0 -Because "$type must exist" + foreach ($resource in $selected) { Wait-LocalBoxResource $resource.id -TimeoutSeconds $TimeoutSeconds | Should -Not -BeNullOrEmpty } + } + } +} + +Describe 'LocalBox runtime health' -Skip:($Mode -ne 'Full') { + It 'has running healthy nested nodes and sufficient storage' { + $IsWindows | Should -BeTrue -Because 'Full LocalBox tests run on LocalBox-Client' + $NodeCredential | Should -Not -BeNullOrEmpty -Because 'Windows guest access is separate from managed identity' + foreach ($node in $LocalBox.NodeNames) { + (Get-VM -Name $node -ErrorAction Stop).State | Should -Be 'Running' + $state = Get-LocalBoxNodeState $node $NodeCredential + $state.NodesUp | Should -BeTrue + $state.PoolHealth | Should -Be 'Healthy' + $state.DiskHealth | Should -Be 'Healthy' + $state.VolumeHealth | Should -Be 'Healthy' + $state.StorageJobs | Should -Be 0 + $state.UnhealthyPhysicalDisks | Should -Be 0 + $state.Size | Should -BeGreaterOrEqual ($LocalBox.StorageSizeGB * 1GB) + $state.Free | Should -BeGreaterOrEqual 100GB + } + } + It 'allows the supplied AKS group-member kubeconfig to query healthy nodes and workloads' { + $minimum = 1 + [int]$LocalBox.AksExpected.properties.agentPoolProfiles[0].count + Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { Test-SovereignKubernetes $LocalBox.Kubeconfig $minimum } + } + It 'can resolve Azure and reach configured gateways from the nested environment' { + $NodeCredential | Should -Not -BeNullOrEmpty + $result = Invoke-Command -VMName $LocalBox.NodeNames[0] -Credential $NodeCredential -ArgumentList (, $LocalBox.Networks) -ErrorAction Stop -ScriptBlock { + param($Networks) + $ErrorActionPreference = 'Stop' + Resolve-DnsName management.azure.com -ErrorAction Stop | Out-Null + foreach ($network in $Networks) { + if (-not (Test-Connection -ComputerName $network.Gateway -Count 2 -Quiet)) { throw "Gateway $($network.Gateway) is unreachable." } + } + (Invoke-WebRequest 'https://management.azure.com/metadata/endpoints?api-version=2020-06-01' -UseBasicParsing -TimeoutSec 30).StatusCode + } + $result | Should -Be 200 + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..b0a138158 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/prepare-localbox.tests.ps1 @@ -0,0 +1,201 @@ +BeforeAll { + . "$PSScriptRoot/../prepare-localbox.ps1" + . "$PSScriptRoot/../test-sovereign-cloud.ps1" +} + +Describe 'Lab cost-control tags' { + It 'tags the LocalBox template and both resource-group paths' { + $source = Get-Content "$PSScriptRoot/../../labautomation/deploy-localbox.ps1" -Raw + $source | Should -Match 'tags\.CostControl=Ignore' + $source | Should -Match "'CostControl=Ignore'" + $source | Should -Match "CostControl\s*=\s*'Ignore'" + } + It 'tags participant resources and both AKS pools' { + $source = Get-Content "$PSScriptRoot/../../labautomation/sovereign-lab.bicep" -Raw + $source | Should -Match "(?s)var tags = \{[^}]*CostControl: 'Ignore'" + $source | Should -Match "(?s)name: 'system'\s+count: 2\s+tags: tags" + $source | Should -Match "(?s)resource confidentialNodePool .*?properties: \{\s+count: 1\s+tags: tags" + } +} + +Describe 'LocalBox input validation' { + It 'accepts the AKS node range while reserving control plane and VIP addresses' { + { Test-LocalBoxAddressPool '10.10.0.0/24' '10.10.0.101' '10.10.0.199' '10.10.0.1' @('10.10.0.5', '10.10.0.10', '10.10.0.100') } | Should -Not -Throw + } + It 'rejects the old network-to-broadcast VM pool' { + { Test-LocalBoxAddressPool '192.168.200.0/24' '192.168.200.0' '192.168.200.255' '192.168.200.1' } | Should -Throw + } + It 'rejects an infrastructure address inside the VM pool' { + { Test-LocalBoxAddressPool '192.168.200.0/24' '192.168.200.10' '192.168.200.220' '192.168.200.1' @('192.168.200.205') } | Should -Throw + } + It 'rejects reversed ranges and foreign gateways' { + { Test-LocalBoxAddressPool '10.10.0.0/24' '10.10.0.199' '10.10.0.101' '10.10.0.1' } | Should -Throw + { Test-LocalBoxAddressPool '10.10.0.0/24' '10.10.0.101' '10.10.0.199' '10.11.0.1' } | Should -Throw + } + It 'rejects a pool wholly contained inside a reserved VIP range' { + { Test-LocalBoxAddressPool '10.10.0.0/24' '10.10.0.20' '10.10.0.30' '10.10.0.1' -ReservedRanges @(@{ Start = '10.10.0.10'; End = '10.10.0.100' }) } | Should -Throw '*overlaps*' + } + It 'rejects missing or malformed group IDs' { + { Assert-LocalBoxGroupId 'LabUsers' } | Should -Throw + { Assert-LocalBoxGroupId ([guid]::Empty.ToString()) } | Should -Throw + } + It 'normalizes a group ID' { + Assert-LocalBoxGroupId 'A0000000-0000-0000-0000-000000000001' | Should -Be 'a0000000-0000-0000-0000-000000000001' + } +} + +Describe 'Resource reconciliation' { + BeforeEach { + $resourceId = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/logicalNetworks/vm-net' + $expected = @{ properties = @{ vmSwitchName = 'external' } } + Mock Invoke-LocalBoxAz { @() } + Mock Get-LocalBoxResource { @{ properties = @{ provisioningState = 'Succeeded'; vmSwitchName = 'external' } } } + Mock Start-Sleep {} + } + It 'does not create a resource in WhatIf mode' { + Sync-LocalBoxResource $resourceId $expected @('resource', 'create') -WhatIf + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'create' } + } + It 'reuses a matching resource without writes' { + Mock Invoke-LocalBoxAz { @(@{ id = $resourceId }) } + Sync-LocalBoxResource $resourceId $expected @('resource', 'create') + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'create' } + } + It 'rejects conflicting properties without deleting or recreating' { + Mock Invoke-LocalBoxAz { @(@{ id = $resourceId }) } + { Sync-LocalBoxResource $resourceId @{ properties = @{ vmSwitchName = 'different' } } @('resource', 'create') } | Should -Throw '*Conflicting*' + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'create' -or $Arguments -contains 'delete' } + } + It 'rejects missing expected properties and empty arrays' { + { Assert-LocalBoxProperties @{} @{ properties = @{ name = 'required' } } } | Should -Throw '*Missing*' + { Assert-LocalBoxProperties @{ nodes = @() } @{ nodes = @('required') } } | Should -Throw '*array*' + } + It 'fails immediately on a failed deployment' { + Mock Get-LocalBoxResource { @{ properties = @{ provisioningState = 'Failed' } } } + { Wait-LocalBoxResource $resourceId } | Should -Throw '*Failed*' + Should -Invoke Start-Sleep -Times 0 + } + It 'bounds waits and reports the unfinished resource' { + Mock Get-LocalBoxResource { @{ properties = @{ provisioningState = 'Creating' } } } + { Wait-LocalBoxResource $resourceId -TimeoutSeconds 0 } | Should -Throw '*Timed out*' + } + It 'preserves the custom-location region instead of the resource-group region' { + { Assert-LocalBoxProperties @{ location = 'swedencentral' } @{ location = 'australiaeast' } } | Should -Throw '*location*' + } + It 'resumes an in-progress resource' { + $script:polls = 0 + Mock Get-LocalBoxResource { + $script:polls++ + @{ properties = @{ provisioningState = $(if ($script:polls -eq 1) { 'Creating' } else { 'Succeeded' }) } } + } + (Wait-LocalBoxResource $resourceId).properties.provisioningState | Should -Be 'Succeeded' + Should -Invoke Start-Sleep -Times 1 + } +} + +Describe 'Storage removal guard' { + BeforeEach { + $state = @{ NodesUp = $true; PoolHealth = 'Healthy'; StorageJobs = 0; SecondaryFiles = @(); UnhealthyPhysicalDisks = 0 } + } + It 'allows an empty idle lab' { + { Assert-LocalBoxStorageRemoval $state @() } | Should -Not -Throw + } + It 'rejects files even when Azure has no workload record' { + $state.SecondaryFiles = @('important.vhdx') + { Assert-LocalBoxStorageRemoval $state @() } | Should -Throw '*not empty*' + } + It 'rejects existing AKS resources even if the volume appears empty' { + { Assert-LocalBoxStorageRemoval $state @(@{ type = 'Microsoft.Kubernetes/connectedClusters' }) } | Should -Throw '*workloads*' + } + It 'rejects an incomplete inventory' { + { Assert-LocalBoxStorageRemoval @{} @() } | Should -Throw + } + It 'rejects ongoing storage jobs' { + $state.StorageJobs = 1 + { Assert-LocalBoxStorageRemoval $state @() } | Should -Throw '*idle*' + } + It 'rejects unhealthy physical disks' { + $state.UnhealthyPhysicalDisks = 1 + { Assert-LocalBoxStorageRemoval $state @() } | Should -Throw '*healthy*' + } +} + +Describe 'Health checks do not report false readiness' { + It 'rejects empty node collections and NotReady nodes' { + { Assert-SovereignNodes @{ items = @() } 1 } | Should -Throw + { Assert-SovereignNodes @{ items = @(@{ metadata = @{ name = 'worker' }; status = @{ conditions = @(@{ type = 'Ready'; status = 'False' }) } }) } 1 } | Should -Throw '*not Ready*' + } + It 'accepts a ready node' { + { Assert-SovereignNodes @{ items = @(@{ metadata = @{ name = 'worker' }; status = @{ conditions = @(@{ type = 'Ready'; status = 'True' }) } }) } 1 } | Should -Not -Throw + } + It 'rejects empty or pending system pods' { + { Assert-SovereignSystemPods @{ items = @() } } | Should -Throw + { Assert-SovereignSystemPods @{ items = @(@{ metadata = @{ namespace = 'kube-system'; name = 'dns' }; status = @{ phase = 'Pending' } }) } } | Should -Throw '*not healthy*' + } + It 'allows completed system jobs without accepting failed workloads' { + { Assert-SovereignSystemPods @{ items = @(@{ metadata = @{ namespace = 'kube-system' }; status = @{ phase = 'Succeeded' } }) } } | Should -Not -Throw + } + It 'never calls control-plane-only results full readiness' { + $result = @{ TotalCount = 5; PassedCount = 5; FailedCount = 0; SkippedCount = 0; NotRunCount = 0 } + Get-SovereignReadiness $result 'ControlPlane' | Should -BeFalse + Get-SovereignReadiness $result 'Full' | Should -BeTrue + $result.SkippedCount = 1 + Get-SovereignReadiness $result 'Full' | Should -BeFalse + } + It 'rejects missing inventory instead of discovering an empty successful lab' { + { Get-SovereignLabOutputs @{ SubscriptionId = 'test'; ResourceGroupName = 'test' } } | Should -Throw '*DeploymentName*' + } + It 'does not retry permission failures' { + Mock Start-Sleep {} + { Wait-SovereignCheck { throw 'AuthorizationFailed' } } | Should -Throw '*AuthorizationFailed*' + Should -Invoke Start-Sleep -Times 0 + } + It 'keeps the requested health mode when helper functions are loaded' { + $mode = 'ControlPlane' + . "$PSScriptRoot/../test-sovereign-cloud.ps1" -Mode $mode -AllowGuestRunCommand + $Mode | Should -Be 'ControlPlane' + $AllowGuestRunCommand | Should -BeTrue + } +} + +Describe 'Storage execution safety' { + It 'uses the documented volume label parameter in nested script blocks' { + $source = Get-Content "$PSScriptRoot/../prepare-localbox.ps1" -Raw + $source | Should -Not -Match 'Get-Volume\s+-FriendlyName' + $source | Should -Match 'Get-Volume\s+-FileSystemLabel' + } + BeforeAll { + function Get-Volume { param($DriveLetter) } + function Get-VHD { param($Path) } + function New-VHD { param($Path, $SizeBytes, [switch]$Dynamic) } + function Get-VMHardDiskDrive { param($VMName) } + function Add-VMHardDiskDrive { param($VMName, $Path) } + } + BeforeEach { + $configuration = @{ NodeHostConfig = @(@{ Hostname = 'AzLHOST1' }, @{ Hostname = 'AzLHOST2' }); HostVMDriveLetter = 'V'; HostVMPath = $TestDrive } + $credential = [pscredential]::new('test-user', [Security.SecureString]::new()) + Mock Get-Volume { @{ HealthStatus = 'Healthy'; SizeRemaining = 300GB } } + Mock Test-Path { $false } + Mock Get-VHD { @{ Size = 1TB; VhdType = 'Dynamic' } } + Mock Get-VMHardDiskDrive { @() } + Mock New-VHD {} + Mock Add-VMHardDiskDrive {} + Mock Invoke-Command {} + Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 679GB } } + } + It 'does not create, attach or resize anything during WhatIf' { + Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -WhatIf + Should -Invoke New-VHD -Times 0 + Should -Invoke Add-VMHardDiskDrive -Times 0 + Should -Invoke Invoke-Command -Times 0 + } + It 'does not duplicate disks or grow an already sized virtual disk on rerun' { + Mock Test-Path { $true } + Mock Get-VMHardDiskDrive { @(@{ Path = Join-Path $TestDrive "$VMName-microhack-s2d.vhdx" }) } + Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 1TB } } + Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -Confirm:$false + Should -Invoke New-VHD -Times 0 + Should -Invoke Add-VMHardDiskDrive -Times 0 + Should -Invoke Invoke-Command -Times 0 -ParameterFilter { $ScriptBlock.ToString() -match 'Resize-VirtualDisk' } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..12f1e1e1d --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/readme.md @@ -0,0 +1,85 @@ +# Sovereign Cloud Pester checks + +There are two kinds of tests: offline mocked script tests, and live health tests against explicitly selected Azure scopes. Live tests do not deploy attendee VMs, install agents, enable paid plans, apply patches or repair failures. They do not replace the [participant VM readiness exercise](../localbox/manual-preparation.md#step-6-test-the-environment). + +## Tooling and authentication + +Use PowerShell 7, Pester 5.7.1 or later within major version 5, Azure CLI, and `kubectl` for Full checks. Offline tests were exercised with PowerShell 7.4.13 and Pester 5.7.1. Live Windows/Azure validation remains an integration gate; these versions are not a claim that all LocalBox versions were tested. + +The runner uses the caller's existing Azure CLI login and never changes it. LocalBox control-plane checks can use its managed identity. Participant-lab checks need an operator authorized for those explicitly listed resource groups; the LocalBox identity is not automatically authorized there. Full LocalBox checks run on LocalBox-Client with a nested-node Windows credential. Do not serialize passwords or tokens in JSON. + +Full Kubernetes checks take dedicated kubeconfigs that already authenticate to the intended clusters. For AKS Local, use an Entra group member in a separate CLI profile/session and the [Jumpstart proxy workflow](https://jumpstart.azure.com/azure_jumpstart_localbox/AKS). Keep the proxy/tunnel running throughout testing. For K3s, use its existing private/Bastion access path. Tests never switch the current kubeconfig or retrieve admin credentials automatically. + +## Download without cloning + +Download [test-sovereign-cloud.ps1](../test-sovereign-cloud.ps1) from the same trusted ref as preparation. The runner's `-DownloadTests` downloads the helper and two suites to a temporary directory; `-GitHubRef` must identify that **same ref**, preferably a commit SHA. Downloaded scripts execute under your privileges, so review and pin them. + +```powershell +$ref = 'main' +$base = "https://raw.githubusercontent.com/microsoft/MicroHack/$ref/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources" +Invoke-WebRequest "$base/test-sovereign-cloud.ps1" -OutFile './test-sovereign-cloud.ps1' +./test-sovereign-cloud.ps1 -Scope LocalBox -Mode ControlPlane ` + -LocalBoxManifestPath 'C:\LocalBox\sovereign-localbox.json' -DownloadTests -GitHubRef $ref +``` + +For a checkout, run the same command without `-DownloadTests`. + +## Full LocalBox check + +```powershell +$nodeCredential = Get-Credential -Message 'Nested Azure Local Windows administrator' +./test-sovereign-cloud.ps1 -Scope LocalBox -Mode Full ` + -LocalBoxManifestPath 'C:\LocalBox\sovereign-localbox.json' ` + -LocalBoxKubeconfig 'C:\LocalBox\aks-local.kubeconfig' ` + -NodeCredential $nodeCredential -DownloadTests -GitHubRef $ref +``` + +Checks cover the Client and bootstrap, cluster/Arc bridge/custom location/AKS extension, supporting Azure resources, image download/storage placement, exact network settings, expected AKS topology/admin group, nested-node/storage health and capacity, Kubernetes node readiness and system pods/controllers. Gateway/DNS/HTTPS probes run from a nested node; these do not prove connectivity from a participant VM that has not yet been created. + +## Participant lab inventory + +Create a local JSON inventory with one entry for every lab to validate. Use the actual successful `sovereign-lab-*` deployment name, not the subscription-level RBAC deployment name. Paths must resolve on the test machine. + +```json +{ + "Labs": [ + { + "SubscriptionId": "00000000-0000-0000-0000-000000000001", + "ResourceGroupName": "rg-your-lab", + "DeploymentName": "sovereign-lab-your-successful-deployment", + "AksKubeconfig": "C:\\LocalBox\\azure-aks.kubeconfig", + "K3sKubeconfig": "C:\\LocalBox\\k3s.kubeconfig" + } + ] +} +``` + +For manual deployments without retained deployment records, replace `DeploymentName` with an `Outputs` object containing the plain nonsecret output values from [sovereign-lab.bicep](../../labautomation/sovereign-lab.bicep). Missing required outputs fail. The runner does not treat whatever happens to exist as the expected inventory. + +```powershell +./test-sovereign-cloud.ps1 -Scope ParticipantLabs -InventoryPath './inventory.json' ` + -Mode Full -AllowGuestRunCommand -DownloadTests -GitHubRef $ref +``` + +`-AllowGuestRunCommand` permits Azure VM Run Command on the two selected VMs solely to check K3s service state, DNS and HTTPS egress. This transport is an Azure management action and requires scoped Run Command permission even though the guest commands are read-only. It can create transient Run Command execution artifacts. Without consent, that mandatory Full check fails; it is not silently skipped. + +Participant checks include VM power/agent state, confidential VM security settings, K3s install and runtime health, Azure AKS/system/confidential pools with OIDC/workload identity/Istio, private networking/NSG/NAT/Bastion configuration and attestation discovery/JWKS. Bastion's interactive user access and an actual confidential-computing attestation exchange remain separate participant smoke tests, not claims made by ARM health queries. + +For both paths together, use `-Scope All`, supply the participant inventory and LocalBox manifest/kubeconfig/Windows credential. Use an operator context authorized for all specified scopes, not a newly broadened managed identity. + +## Results + +- `health-results/health.xml`: NUnit-format Pester test report with failure detail. +- `health-results/health.json`: names/results/counts and a `FullReadiness` flag; no passwords or kubeconfig contents. +- Nonzero exit: a failure, or incomplete Full coverage. Missing credentials/permissions/connectivity is not a healthy result. +- A successful `ControlPlane` run is explicitly partial and never sets `FullReadiness`. Full readiness covers the checks described above, not application functionality or uncreated student resources. + +Waits are bounded by `-TimeoutMinutes` per check. Terminal provisioning failures and authorization errors stop promptly. Secure the reports as operational metadata; underlying tool errors in XML may include resource IDs and tenant information. Retest after resolving failures rather than mutating infrastructure from the health suite. + +## Offline regression tests + +```powershell +Invoke-Pester ./tests/prepare-localbox.tests.ps1 -Output Detailed +``` + +Run from the resources directory. This suite tests address and group validation, resource reuse/conflicts, partial-state polling/timeouts, destructive-operation guards, and refusal to report incomplete Kubernetes results as full readiness. It needs no Azure login or infrastructure. Do not run the entire tests folder by default: the two `*.health.tests.ps1` suites require runner-supplied data and live access. \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/sovereign-lab.health.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/sovereign-lab.health.tests.ps1 new file mode 100644 index 000000000..a0576414d --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/sovereign-lab.health.tests.ps1 @@ -0,0 +1,118 @@ +param([hashtable]$Lab, [string]$Mode, [int]$TimeoutSeconds, [string]$ResourceRoot, [bool]$AllowGuestRunCommand) + +BeforeAll { + . "$ResourceRoot/prepare-localbox.ps1" + . "$ResourceRoot/test-sovereign-cloud.ps1" -Mode $Mode -AllowGuestRunCommand:$AllowGuestRunCommand + $outputs = Get-SovereignLabOutputs $Lab + foreach ($required in @('aksClusterName', 'confidentialNodePoolName', 'confidentialVmName', 'k3sVmName', 'k3sInstallStatusResource', 'attestationProviderName', 'attestationProviderUri', 'bastionName', 'natEgressIp')) { + if (-not $outputs[$required]) { throw "Missing deployment output $required." } + } + $scopeId = "/subscriptions/$($Lab.SubscriptionId)/resourceGroups/$($Lab.ResourceGroupName)" + $common = @('--subscription', $Lab.SubscriptionId, '--resource-group', $Lab.ResourceGroupName) + $resources = @(Invoke-LocalBoxAz (@('resource', 'list') + $common)) +} + +Describe 'Participant lab control plane' { + It 'has both running VMs with healthy agents' { + foreach ($name in @($outputs.k3sVmName, $outputs.confidentialVmName)) { + $vm = Invoke-LocalBoxAz (@('vm', 'get-instance-view', '--name', $name) + $common) + $vm.provisioningState | Should -Be 'Succeeded' + $vm.instanceView.statuses.code | Should -Contain 'PowerState/running' + $vm.instanceView.vmAgent.statuses.code | Should -Contain 'ProvisioningState/succeeded' + } + } + It 'has a confidential VM with secure boot and vTPM' { + $vm = Get-LocalBoxResource "$scopeId/providers/Microsoft.Compute/virtualMachines/$($outputs.confidentialVmName)" + $vm.properties.securityProfile.securityType | Should -Be 'ConfidentialVM' + $vm.properties.securityProfile.uefiSettings.secureBootEnabled | Should -BeTrue + $vm.properties.securityProfile.uefiSettings.vTpmEnabled | Should -BeTrue + $vm.properties.storageProfile.osDisk.managedDisk.securityProfile.securityEncryptionType | Should -Be 'VMGuestStateOnly' + $vm.properties.hardwareProfile.vmSize | Should -Match '^Standard_DC\d+as_v[56]$' + } + It 'has completed the K3s install extension' { + Wait-LocalBoxResource $outputs.k3sInstallStatusResource -TimeoutSeconds $TimeoutSeconds | Should -Not -BeNullOrEmpty + } + It 'has the Azure AKS platform and confidential pool specified by the template' { + $aksId = "$scopeId/providers/Microsoft.ContainerService/managedClusters/$($outputs.aksClusterName)" + $aks = Wait-LocalBoxResource $aksId -TimeoutSeconds $TimeoutSeconds + $aks.properties.enableRBAC | Should -BeTrue + $aks.properties.oidcIssuerProfile.enabled | Should -BeTrue + $aks.properties.securityProfile.workloadIdentity.enabled | Should -BeTrue + $aks.properties.serviceMeshProfile.mode | Should -Be 'Istio' + $aks.properties.networkProfile.networkPluginMode | Should -Be 'overlay' + $system = Wait-LocalBoxResource "$aksId/agentPools/system" -TimeoutSeconds $TimeoutSeconds + $system.properties.count | Should -Be 2 + $system.properties.mode | Should -Be 'System' + $system.properties.vmSize | Should -Be 'Standard_D4s_v5' + $pool = Wait-LocalBoxResource "$aksId/agentPools/$($outputs.confidentialNodePoolName)" -TimeoutSeconds $TimeoutSeconds + $pool.properties.count | Should -Be 1 + $pool.properties.osSKU | Should -Be 'AzureLinux' + $pool.properties.vmSize | Should -Match '^Standard_DC\d+as_v[56]$' + } + It 'has all expected network components and private VM NICs' { + foreach ($type in @('Microsoft.Network/virtualNetworks', 'Microsoft.Network/networkSecurityGroups', 'Microsoft.Network/networkInterfaces', 'Microsoft.Network/natGateways', 'Microsoft.Network/publicIPAddresses', 'Microsoft.Network/bastionHosts')) { + @($resources | Where-Object type -ieq $type).Count | Should -BeGreaterThan 0 -Because "$type must exist" + } + foreach ($name in @($outputs.k3sVmName, $outputs.confidentialVmName)) { + $vm = Get-LocalBoxResource "$scopeId/providers/Microsoft.Compute/virtualMachines/$name" + $nicId = $vm.properties.networkProfile.networkInterfaces[0].id + $nic = Wait-LocalBoxResource $nicId -TimeoutSeconds $TimeoutSeconds + $nic.properties.networkSecurityGroup.id | Should -Not -BeNullOrEmpty + $nic.properties.ipConfigurations[0].properties.publicIPAddress | Should -BeNullOrEmpty + $expectedIp = if ($name -eq $outputs.k3sVmName) { '10.42.0.4' } else { '10.42.2.4' } + $nic.properties.ipConfigurations[0].properties.privateIPAddress | Should -Be $expectedIp + $subnetId = $nic.properties.ipConfigurations[0].properties.subnet.id + $subnet = Get-LocalBoxResource $subnetId + $subnet.properties.networkSecurityGroup.id | Should -Be $nic.properties.networkSecurityGroup.id + if ($name -eq $outputs.k3sVmName) { + $subnet.properties.defaultOutboundAccess | Should -BeFalse + $nat = Wait-LocalBoxResource $subnet.properties.natGateway.id -TimeoutSeconds $TimeoutSeconds + $ip = Get-LocalBoxResource $nat.properties.publicIpAddresses[0].id + $ip.properties.ipAddress | Should -Be $outputs.natEgressIp + } + $nsg = Get-LocalBoxResource $nic.properties.networkSecurityGroup.id + $ssh = @($nsg.properties.securityRules | Where-Object name -eq 'AllowBastionSsh') + $ssh.Count | Should -Be 1 + $ssh[0].properties.sourceAddressPrefix | Should -Be '10.42.1.0/26' + $ssh[0].properties.destinationPortRange | Should -Be '22' + } + $bastion = Wait-LocalBoxResource "$scopeId/providers/Microsoft.Network/bastionHosts/$($outputs.bastionName)" -TimeoutSeconds $TimeoutSeconds + $bastion.sku.name | Should -Be 'Standard' + $bastion.properties.enableTunneling | Should -BeTrue + $bastionSubnet = Get-LocalBoxResource $bastion.properties.ipConfigurations[0].properties.subnet.id + $bastionSubnet.properties.addressPrefix | Should -Be '10.42.1.0/26' + } + It 'has a provisioned attestation provider and valid discovery endpoint' { + Wait-LocalBoxResource "$scopeId/providers/Microsoft.Attestation/attestationProviders/$($outputs.attestationProviderName)" -TimeoutSeconds $TimeoutSeconds | Should -Not -BeNullOrEmpty + $uri = [uri]$outputs.attestationProviderUri + $uri.Scheme | Should -Be 'https' + $discovery = Invoke-RestMethod -Uri "$($uri.AbsoluteUri.TrimEnd('/'))/.well-known/openid-configuration" -TimeoutSec 30 + $discovery.jwks_uri | Should -Not -BeNullOrEmpty + ([uri]$discovery.jwks_uri).Host | Should -Be $uri.Host + @((Invoke-RestMethod -Uri $discovery.jwks_uri -TimeoutSec 30).keys).Count | Should -BeGreaterThan 0 + } +} + +Describe 'Participant lab runtime health' -Skip:($Mode -ne 'Full') { + It 'has healthy Azure AKS nodes, confidential nodes and system workloads' { + Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { + Test-SovereignKubernetes $Lab.AksKubeconfig 3 + $nodes = Invoke-SovereignKubectl $Lab.AksKubeconfig @('get', 'nodes') + @($nodes.items | Where-Object { $_.metadata.labels.agentpool -eq 'system' }).Count | Should -Be 2 + @($nodes.items | Where-Object { $_.metadata.labels.agentpool -eq $outputs.confidentialNodePoolName }).Count | Should -Be 1 + } + } + It 'has a reachable healthy K3s API and system workloads' { + Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { Test-SovereignKubernetes $Lab.K3sKubeconfig 1 } + } + It 'has working guest DNS and outbound connectivity, and an active K3s service' { + $AllowGuestRunCommand | Should -BeTrue -Because 'Full guest probes require explicit -AllowGuestRunCommand; no resources are installed or changed' + foreach ($name in @($outputs.k3sVmName, $outputs.confidentialVmName)) { + $script = "set -eu`ngetent hosts management.azure.com >/dev/null`ncurl --fail --silent --show-error --max-time 30 'https://management.azure.com/metadata/endpoints?api-version=2020-06-01' >/dev/null`n" + if ($name -eq $outputs.k3sVmName) { $script += "systemctl is-active --quiet k3s`n" } + $script += "printf '\nMICROHACK_GUEST_HEALTH_OK\n'" + $result = Invoke-LocalBoxAz (@('vm', 'run-command', 'invoke', '--name', $name, '--command-id', 'RunShellScript', '--scripts', $script) + $common) -TimeoutSeconds 300 + ($result.value.message -join "`n") | Should -Match '(?m)^MICROHACK_GUEST_HEALTH_OK\r?$' + } + } +} \ No newline at end of file From 863610366fac3586f95fe3fc72407b4974ffd21f Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Sun, 20 Sep 2026 17:48:31 +0000 Subject: [PATCH 2/5] Fix LocalBox preparation and align AKS and image defaults --- .../resources/localbox/readme.md | 11 +- .../resources/prepare-localbox.ps1 | 115 ++++++++++--- .../resources/test-sovereign-cloud.ps1 | 17 +- .../resources/tests/localbox.health.tests.ps1 | 11 +- .../tests/prepare-localbox.tests.ps1 | 159 +++++++++++++++++- .../resources/tests/readme.md | 27 +++ 6 files changed, 304 insertions(+), 36 deletions(-) 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 cc01ef2a2..a9b752d34 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 @@ -5,6 +5,7 @@ Run [prepare-localbox.ps1](../prepare-localbox.ps1) **inside LocalBox-Client**, ## Prerequisites - Elevated PowerShell 7 on `LocalBox-Client`; installed Azure CLI with `stack-hci-vm`, `customlocation` and `aksarc` extensions. Install missing extensions explicitly before running; the script neither installs nor upgrades tooling. Record the versions used for your event. +- Do not assume a new Jumpstart Client includes these CLI extensions. Check `az extension list` in the profile you will use. Keep any existing extensions; install only missing ones. Health checks additionally require Pester 5.7.1 or later within major version 5; an installed Pester 6 is not a substitute for the runner's supported version. - Healthy Azure Local nodes, storage, Arc Resource Bridge, `jumpstart` custom location and `hybridaksextension`. - The Client VM's system-assigned managed identity and its existing LocalBox resource-group permissions. The script uses a temporary isolated CLI login and restores the caller's profile. No subscription-wide or Graph permissions are added. - An existing Entra AKS admin-group object ID. Hosted events obtain this from the Console owner; [group provisioning is a documented dependency](../hosted-events/readme.md#console-group-dependency). Group membership is not validated through Graph by this script. @@ -26,6 +27,8 @@ $nodeCredential = Get-Credential -Message 'Nested Azure Local Windows administra The script prompts for the Entra group object ID, or accepts `-AksAdminGroupObjectId`. Subscription, resource group and configuration path default from Jumpstart's machine environment. Explicit scope values must match this Client's deployment. Run only one preparation session at a time. +If the Console-owned group is not available yet, `-SkipAks` explicitly prepares only storage, the VM image and both networks. It does not fabricate a group or grant anyone cluster access. The manifest records this as a partial run, and full health checks fail until preparation is rerun without `-SkipAks` using a valid admin-group ID. + `-WhatIf` performs authenticated reads and local validation, but no Azure/Hyper-V/storage provisioning. It can create and remove a temporary local CLI profile. It does not establish that later resource creation will succeed. ## Prepared resources @@ -33,15 +36,19 @@ The script prompts for the Entra group object ID, or accepts `-AksAdminGroupObje | Resource | Default | | --- | --- | | Storage | One stable-named 1 TiB dynamic VHDX per node; grow `UserStorage_1` to 1 TiB, never shrink or grow it again on every rerun | -| VM image | `localbox-windows-server-2025`, Marketplace Windows Server 2025 Azure Edition smalldisk, explicitly on `UserStorage1` | +| VM image | `2025-datacenter-azure-edition-smalldisk-01`, matching the walkthrough screenshot; Marketplace Windows Server 2025 Azure Edition smalldisk, explicitly on `UserStorage1` | | VM network | `localbox-vm-lnet-vlan200`, VLAN 200, `192.168.200.0/24`, pool `.10-.199` after reservation review | | AKS network | `localbox-aks-lnet-vlan110`, VLAN 110, `10.10.0.0/24`, nodes `.101-.199` | | AKS control plane / service reservation | `10.10.0.5`; `.10-.100` reserved for future service VIPs; this script does not install a load balancer | -| AKS cluster | `localbox-aks`, one control-plane and one Linux worker; `Standard_A4_v2` defaults, subject to installed-version capacity/CLI validation | +| AKS cluster | `localbox-aks`, one control-plane node and three Linux workers; `Standard_A4_v2` defaults, subject to installed-version capacity/CLI validation | | Output | `C:\LocalBox\sovereign-localbox.json`, nonsecret IDs and expected configuration for health tests | Topology defaults come from the installed Jumpstart config, except the VM pool bounds and configurable resource names/sizes. The network/broadcast addresses, gateways and Jumpstart infrastructure reservations must not be allocated. `-AddressReservationsConfirmed` is your explicit confirmation that DHCP/static reservations were reviewed; the script cannot safely infer all DHCP leases from Azure. It does not change routers or DHCP configuration. +Azure Local may append generated suffixes to its `UserStorage1` and `UserStorage2` resource names. The script resolves the actual storage-container ID and verifies its custom location; ambiguous names fail rather than selecting the first match. + +On Windows, the script invokes Azure CLI through its bundled Python executable rather than `az.cmd`. This preserves arguments such as `ConvergedSwitch(compute_management)` that the batch wrapper otherwise interprets as command syntax. Keep the standard Azure CLI installation layout intact. + Use `-ImageVersion` to pin a Marketplace version, `-KubernetesVersion` for a supported AKS version, and size/count overrides when required. Images can take several hours; `-TimeoutMinutes` defaults to 360 per long operation. A timed-out local command does not cancel an already submitted Azure operation. Inspect status before rerunning. Matching resources are reused; incompatible resources produce an error, never automatic replacement. ## Optional storage consolidation diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 index a5ef06248..33bc87069 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 @@ -13,9 +13,10 @@ param( [string]$ResourceGroupName = $env:resourceGroup, [string]$ConfigPath = $env:LocalBoxConfigFile, [string]$AksAdminGroupObjectId, + [switch]$SkipAks, [pscredential]$NodeCredential, [string]$VmSwitchName, - [string]$ImageName = 'localbox-windows-server-2025', + [string]$ImageName = '2025-datacenter-azure-edition-smalldisk-01', [string]$ImageVersion = 'latest', [string]$VmPoolStart = '192.168.200.10', [string]$VmPoolEnd = '192.168.200.199', @@ -23,7 +24,7 @@ param( [string]$KubernetesVersion, [string]$NodeVmSize = 'Standard_A4_v2', [string]$ControlPlaneVmSize = 'Standard_A4_v2', - [ValidateRange(1, 10)][int]$NodeCount = 1, + [ValidateRange(1, 10)][int]$NodeCount = 3, [ValidateRange(512, 4096)][int]$StorageSizeGB = 1024, [switch]$RemoveUserStorage2, [switch]$AddressReservationsConfirmed, @@ -88,11 +89,32 @@ function Assert-LocalBoxGroupId { return $identifier.ToString() } +function Read-LocalBoxGroupId { + param([string]$Value, [switch]$SkipAks) + if ($SkipAks) { return $null } + if (-not $Value) { $Value = Read-Host 'Entra AKS admin-group object ID (provided by Console; group must already exist)' } + return Assert-LocalBoxGroupId $Value +} + +function Get-LocalBoxAzInvocation { + param([string]$CommandPath = (Get-Command az -ErrorAction Stop).Source) + if ([IO.Path]::GetExtension($CommandPath) -in @('.cmd', '.bat')) { + $python = [IO.Path]::GetFullPath((Join-Path (Split-Path $CommandPath) '../python.exe')) + if (-not (Test-Path -LiteralPath $python -PathType Leaf)) { + throw 'Cannot locate the Azure CLI bundled Python. Use a supported Azure CLI installation; the batch wrapper is unsafe for switch names containing parentheses.' + } + return @{ Executable = $python; Prefix = @('-IBm', 'azure.cli') } + } + return @{ Executable = $CommandPath; Prefix = @() } +} + function Invoke-LocalBoxAz { param([Parameter(Mandatory)][string[]]$Arguments, [int]$TimeoutSeconds = 300) - $job = Start-Job -ArgumentList (, $Arguments) -ScriptBlock { - param($CommandArguments) - $output = & az @CommandArguments --only-show-errors --output json 2>&1 + $invocation = Get-LocalBoxAzInvocation + $job = Start-Job -ArgumentList $invocation, (, $Arguments) -ScriptBlock { + param($Invocation, $CommandArguments) + $prefix = $Invocation.Prefix + $output = & $Invocation.Executable @prefix @CommandArguments --only-show-errors --output json 2>&1 if ($LASTEXITCODE -ne 0) { throw ($output -join "`n") } $text = $output -join "`n" if ($text.Trim()) { $text | ConvertFrom-Json -AsHashtable -ErrorAction Stop } @@ -103,12 +125,34 @@ function Invoke-LocalBoxAz { } Receive-Job $job -ErrorAction Stop } - finally { Remove-Job $job -Force -ErrorAction SilentlyContinue } + finally { Remove-Job $job -Force -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false } } function Get-LocalBoxResource { param([string]$Id) - Invoke-LocalBoxAz @('resource', 'show', '--ids', $Id) + $arguments = @('resource', 'show', '--ids', $Id) + if ($Id -match '/providers/Microsoft\.HybridContainerService/provisionedClusterInstances/') { + $arguments += @('--api-version', '2024-01-01') + } + Invoke-LocalBoxAz $arguments +} + +function Resolve-LocalBoxStoragePath { + param( + [array]$Resources, + [ValidateSet('UserStorage1', 'UserStorage2')][string]$Name, + [string]$CustomLocationId, + [switch]$AllowMissing + ) + $matches = @($Resources | Where-Object { + $_.type -ieq 'Microsoft.AzureStackHCI/storageContainers' -and $_.name -match "^$Name(-[a-zA-Z0-9]+)?$" + }) + if ($matches.Count -eq 0 -and $AllowMissing) { return $null } + if ($matches.Count -ne 1) { throw "Expected exactly one $Name storage path; found $($matches.Count)." } + $storage = Get-LocalBoxResource $matches[0].id + if ($storage.extendedLocation.name -ine $CustomLocationId) { throw "$Name belongs to a different custom location." } + if ($storage.properties.provisioningState -ne 'Succeeded') { throw "$Name storage path is not ready." } + return $storage } function Wait-LocalBoxResource { @@ -166,7 +210,7 @@ function Sync-LocalBoxResource { function Get-LocalBoxNodeState { param([string]$Node, [pscredential]$Credential) - Invoke-Command -VMName $Node -Credential $Credential -ErrorAction Stop -ScriptBlock { + $state = Invoke-Command -VMName $Node -Credential $Credential -ErrorAction Stop -ScriptBlock { $ErrorActionPreference = 'Stop' $pool = Get-StoragePool -FriendlyName 'SU1_Pool' $disk = Get-VirtualDisk -FriendlyName 'UserStorage_1' @@ -193,10 +237,13 @@ function Get-LocalBoxNodeState { SecondaryFiles = @($files | Select-Object -ExpandProperty Name) SecondaryCsv = if ($secondaryCsv.Count) { $secondaryCsv[0].Name } else { '' } NodesUp = @(Get-ClusterNode | Where-Object State -ne 'Up').Count -eq 0 - StorageJobs = @(Get-StorageJob).Count + StorageJobs = 0 + StorageJobStates = @(Get-StorageJob | ForEach-Object { [string]$_.JobState }) UnhealthyPhysicalDisks = @(Get-PhysicalDisk -StoragePool $pool | Where-Object HealthStatus -ne 'Healthy').Count } } + $state.StorageJobs = @($state.StorageJobStates | Where-Object { $_ -ne 'Completed' }).Count + return $state } function Assert-LocalBoxStorageRemoval { @@ -213,6 +260,19 @@ function Assert-LocalBoxStorageRemoval { if ($workloads.Count) { throw 'Existing LocalBox workloads/images/disks found. Review and consolidate storage manually before provisioning.' } } +function Wait-LocalBoxStorageReady { + param([string]$Node, [pscredential]$Credential, [int]$TimeoutSeconds = 1800) + $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + $state = Get-LocalBoxNodeState $Node $Credential + if ($state.PoolHealth -eq 'Healthy' -and $state.DiskHealth -eq 'Healthy' -and $state.VolumeHealth -eq 'Healthy' -and + $state.NodesUp -and $state.StorageJobs -eq 0 -and $state.UnhealthyPhysicalDisks -eq 0) { return $state } + if ([datetime]::UtcNow -ge $deadline) { throw "Timed out waiting for healthy, idle storage on $Node. No dependent operations were started." } + Write-Host "Waiting for storage on $Node (pool=$($state.PoolHealth), jobs=$($state.StorageJobs), unhealthy disks=$($state.UnhealthyPhysicalDisks))..." + Start-Sleep -Seconds 15 + } while ($true) +} + function Initialize-LocalBoxStorage { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( @@ -238,17 +298,15 @@ function Initialize-LocalBoxStorage { Add-VMHardDiskDrive -VMName $node -Path $path -ErrorAction Stop } } - $state = Get-LocalBoxNodeState $nodes[0] $Credential - if ($state.PoolHealth -ne 'Healthy' -or $state.DiskHealth -ne 'Healthy' -or -not $state.NodesUp -or $state.StorageJobs -or $state.UnhealthyPhysicalDisks) { - throw 'Storage is not healthy/idle. Allow disk discovery and storage jobs to finish, then rerun.' - } + $state = Wait-LocalBoxStorageReady $nodes[0] $Credential if ($RemoveSecondary) { $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--subscription', $Subscription, '--resource-group', $ResourceGroup)) - $path = @($resources | Where-Object { $_.type -ieq 'Microsoft.AzureStackHCI/storagecontainers' -and $_.name -ieq 'UserStorage2' }) - if ($state.SecondaryPresent -or $path.Count) { + $customLocationId = "/subscriptions/$Subscription/resourceGroups/$ResourceGroup/providers/Microsoft.ExtendedLocation/customLocations/$($Configuration.rbCustomLocationName)" + $path = Resolve-LocalBoxStoragePath -Resources $resources -Name UserStorage2 -CustomLocationId $customLocationId -AllowMissing + if ($state.SecondaryPresent -or $path) { Assert-LocalBoxStorageRemoval $state $resources if ($PSCmdlet.ShouldProcess('UserStorage2 / UserStorage_2', 'Permanently remove EMPTY storage path, CSV and virtual disk')) { - if ($path.Count) { Invoke-LocalBoxAz @('resource', 'delete', '--ids', $path[0].id) | Out-Null } + if ($path) { Invoke-LocalBoxAz @('resource', 'delete', '--ids', $path.id) | Out-Null } if ($state.SecondaryPresent) { Invoke-Command -VMName $nodes[0] -Credential $Credential -ErrorAction Stop -ScriptBlock { $ErrorActionPreference = 'Stop' @@ -306,12 +364,7 @@ function Invoke-LocalBoxPreparation { if ($Settings.SubscriptionId -ine $env:subscriptionId -or $Settings.ResourceGroupName -ine $env:resourceGroup) { throw 'Requested Azure scope does not match this LocalBox deployment.' } - $groupId = $Settings.AksAdminGroupObjectId - while (-not $groupId) { - $candidate = Read-Host 'Entra AKS admin-group object ID (provided by Console; group must already exist)' - try { $groupId = Assert-LocalBoxGroupId $candidate } catch { Write-Warning $_.Exception.Message } - } - $groupId = Assert-LocalBoxGroupId $groupId + $groupId = Read-LocalBoxGroupId -Value $Settings.AksAdminGroupObjectId -SkipAks:$Settings.SkipAks Test-LocalBoxAddressPool $config.vmIpPrefix $Settings.VmPoolStart $Settings.VmPoolEnd $config.vmGateway @($config.dcVLAN200IP) $vipRange = @{ Start = $config.AKSVIPStartIP; End = $config.AKSVIPEndIP } Test-LocalBoxAddressPool $config.AKSIPPrefix $config.AKSNodeStartIP $config.AKSNodeEndIP $config.AKSGWIP @($config.AKSControlPlaneIP) -ReservedRanges @($vipRange) @@ -336,6 +389,7 @@ function Invoke-LocalBoxPreparation { } $env:AZURE_CONFIG_DIR = $profile $env:AZURE_EXTENSION_USE_DYNAMIC_INSTALL = 'no' + Write-Host 'Checking managed identity, Azure resources and CLI prerequisites...' Invoke-LocalBoxAz @('login', '--identity') | Out-Null Invoke-LocalBoxAz @('account', 'set', '--subscription', $Settings.SubscriptionId) | Out-Null foreach ($extension in @('stack-hci-vm', 'customlocation', 'aksarc')) { @@ -357,6 +411,7 @@ function Invoke-LocalBoxPreparation { $extensions = @($custom.properties.clusterExtensionIds | Where-Object { $_ -match '/hybridaksextension$' }) if ($extensions.Count -ne 1 -or (Get-LocalBoxResource $extensions[0]).properties.provisioningState -ne 'Succeeded') { throw 'hybridaksextension is not ready.' } foreach ($node in $config.NodeHostConfig.Hostname) { + Write-Host "Checking nested node $node and storage health..." if ((Get-VM -Name $node).State -ne 'Running') { throw "Nested node $node is not running." } $state = Get-LocalBoxNodeState $node $Settings.NodeCredential if (-not $state.NodesUp -or $state.PoolHealth -ne 'Healthy') { throw "Nested cluster/storage is not healthy on $node." } @@ -368,13 +423,13 @@ function Invoke-LocalBoxPreparation { } $provider = Invoke-LocalBoxAz @('provider', 'show', '--namespace', 'Microsoft.EdgeMarketplace') if ($provider.registrationState -ne 'Registered') { throw 'Ask the subscription administrator to register Microsoft.EdgeMarketplace before importing the image.' } + Write-Host 'Preparing nested disks and UserStorage_1; secondary storage is preserved unless explicitly requested...' Initialize-LocalBoxStorage -Configuration $config -Credential $Settings.NodeCredential -DesiredSizeGB $Settings.StorageSizeGB ` -Subscription $Settings.SubscriptionId -ResourceGroup $Settings.ResourceGroupName -RemoveSecondary:$Settings.RemoveUserStorage2 $state = Get-LocalBoxNodeState $config.NodeHostConfig[0].Hostname $Settings.NodeCredential if (-not $WhatIfPreference -and ($state.Size -lt ($Settings.StorageSizeGB * 1GB) -or $state.Free -lt 100GB)) { throw 'UserStorage_1 is too small or has less than 100 GiB free.' } - $storageId = "$scope/providers/Microsoft.AzureStackHCI/storageContainers/UserStorage1" - $storage = Get-LocalBoxResource $storageId - if ($storage.properties.provisioningState -ne 'Succeeded') { throw 'UserStorage1 Azure storage path is not ready.' } + $storage = Resolve-LocalBoxStoragePath -Resources $resources -Name UserStorage1 -CustomLocationId $custom.id + $storageId = $storage.id $location = $custom.location $timeout = $Settings.TimeoutMinutes * 60 $common = @('--subscription', $Settings.SubscriptionId, '--resource-group', $Settings.ResourceGroupName, '--location', $location, '--custom-location', $custom.id) @@ -386,6 +441,7 @@ function Invoke-LocalBoxPreparation { } } } if ($Settings.ImageVersion -ne 'latest') { $imageExpected.properties.version = @{ name = $Settings.ImageVersion } } + Write-Host "Preparing VM image $($Settings.ImageName); download can take several hours..." $image = Sync-LocalBoxResource -Id $imageId -Expected $imageExpected -TimeoutSeconds $timeout -CreateArguments (@('stack-hci-vm', 'image', 'create') + $common + @( '--name', $Settings.ImageName, '--os-type', 'Windows', '--publisher', 'microsoftwindowsserver', '--offer', 'windowsserver', '--sku', '2025-datacenter-azure-edition-smalldisk', '--version', $Settings.ImageVersion, '--storage-path-id', $storageId)) @@ -394,6 +450,7 @@ function Invoke-LocalBoxPreparation { @{ Name = 'localbox-aks-lnet-vlan110'; Prefix = $config.AKSIPPrefix; Gateway = $config.AKSGWIP; Dns = $config.AKSDNSIP; Vlan = $config.AKSVLAN; Start = $config.AKSNodeStartIP; End = $config.AKSNodeEndIP } ) foreach ($network in $networks) { + Write-Host "Preparing logical network $($network.Name)..." $network.Id = "$scope/providers/Microsoft.AzureStackHCI/logicalNetworks/$($network.Name)" $network.Expected = @{ extendedLocation = @{ name = $custom.id }; location = $location @@ -416,7 +473,7 @@ function Invoke-LocalBoxPreparation { } } if ($Settings.KubernetesVersion) { $aksExpected.properties.kubernetesVersion = $Settings.KubernetesVersion } $existingAks = @((Invoke-LocalBoxAz @('resource', 'list', '--resource-group', $Settings.ResourceGroupName)) | Where-Object id -ieq $aksId) - if (-not $existingAks.Count) { + if (-not $Settings.SkipAks -and -not $existingAks.Count) { $arguments = @('aksarc', 'create') + $common + @('--name', $Settings.AksClusterName, '--vnet-ids', $networks[1].Id, '--aad-admin-group-object-ids', $groupId, '--generate-ssh-keys', '--control-plane-ip', $config.AKSControlPlaneIP, '--node-count', [string]$Settings.NodeCount, '--node-vm-size', $Settings.NodeVmSize, '--control-plane-count', '1', '--control-plane-vm-size', $Settings.ControlPlaneVmSize) @@ -426,7 +483,7 @@ function Invoke-LocalBoxPreparation { Invoke-LocalBoxAz $arguments -TimeoutSeconds $timeout | Out-Null } } - if (-not $WhatIfPreference) { + if (-not $Settings.SkipAks -and -not $WhatIfPreference) { $instance = Wait-LocalBoxResource $instanceId -TimeoutSeconds $timeout Assert-LocalBoxProperties $instance $aksExpected $connected = Wait-LocalBoxResource $aksId -TimeoutSeconds $timeout @@ -445,10 +502,12 @@ function Invoke-LocalBoxPreparation { Networks = $networks; AksId = $aksId; AksInstanceId = $instanceId; AksExpected = $aksExpected; AksAdminGroupObjectId = $groupId NodeNames = @($config.NodeHostConfig.Hostname); StorageSizeGB = $Settings.StorageSizeGB; PreparedAt = [datetime]::UtcNow.ToString('o') FullHealthVerified = $false + AksPreparationSkipped = [bool]$Settings.SkipAks } if ($PSCmdlet.ShouldProcess($Settings.ManifestPath, 'Write nonsecret preparation manifest')) { $manifest | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Settings.ManifestPath -Encoding utf8 } + if ($Settings.SkipAks) { Write-Warning 'AKS was explicitly skipped. Rerun with an admin-group ID before full health validation; this environment is not event-ready.' } Write-Host 'Preparation finished. Run test-sovereign-cloud.ps1 before declaring the environment ready.' } finally { @@ -464,7 +523,7 @@ function Invoke-LocalBoxPreparation { if ($MyInvocation.InvocationName -ne '.') { $settings = @{ SubscriptionId = $SubscriptionId; ResourceGroupName = $ResourceGroupName; ConfigPath = $ConfigPath - AksAdminGroupObjectId = $AksAdminGroupObjectId; NodeCredential = $NodeCredential; VmSwitchName = $VmSwitchName + AksAdminGroupObjectId = $AksAdminGroupObjectId; SkipAks = [bool]$SkipAks; NodeCredential = $NodeCredential; VmSwitchName = $VmSwitchName ImageName = $ImageName; ImageVersion = $ImageVersion; VmPoolStart = $VmPoolStart; VmPoolEnd = $VmPoolEnd AksClusterName = $AksClusterName; KubernetesVersion = $KubernetesVersion; NodeVmSize = $NodeVmSize ControlPlaneVmSize = $ControlPlaneVmSize; NodeCount = $NodeCount; StorageSizeGB = $StorageSizeGB diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 index abc725acd..a100c53b6 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-sovereign-cloud.ps1 @@ -22,6 +22,8 @@ param( [switch]$AllowGuestRunCommand, [ValidateRange(1, 120)][int]$TimeoutMinutes = 15, [string]$OutputDirectory = './health-results', + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$GitHubRepository = 'microsoft/MicroHack', [string]$GitHubRef = 'main', [switch]$DownloadTests ) @@ -43,6 +45,19 @@ function Assert-SovereignNodes { } } +function Assert-SovereignLocalNodeCount { + param($Nodes, [int]$WorkerCount, [int]$ControlPlaneCount = 1) + Assert-SovereignNodes $Nodes ($WorkerCount + $ControlPlaneCount) + $controlPlaneNodes = @($Nodes.items | Where-Object { + $labels = $_.metadata.labels + $labels -and ($labels.ContainsKey('node-role.kubernetes.io/control-plane') -or $labels.ContainsKey('node-role.kubernetes.io/master')) + }) + $workers = @($Nodes.items).Count - $controlPlaneNodes.Count + if ($controlPlaneNodes.Count -ne $ControlPlaneCount -or $workers -ne $WorkerCount) { + throw "Expected $ControlPlaneCount control-plane nodes and $WorkerCount workers; found $($controlPlaneNodes.Count) and $workers." + } +} + function Assert-SovereignSystemPods { param($Pods) $systemPods = @($Pods.items | Where-Object { $_.metadata.namespace -in @('kube-system', 'azure-arc', 'aks-istio-system') }) @@ -110,7 +125,7 @@ if ($MyInvocation.InvocationName -ne '.') { if ($DownloadTests) { $root = Join-Path ([IO.Path]::GetTempPath()) "microhack-health-$([guid]::NewGuid())" New-Item -ItemType Directory -Path "$root/tests" -Force | Out-Null - $base = "https://raw.githubusercontent.com/microsoft/MicroHack/$GitHubRef/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources" + $base = "https://raw.githubusercontent.com/$GitHubRepository/$GitHubRef/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources" foreach ($file in @('prepare-localbox.ps1', 'test-sovereign-cloud.ps1', 'tests/localbox.health.tests.ps1', 'tests/sovereign-lab.health.tests.ps1')) { Invoke-WebRequest -Uri "$base/$file" -OutFile (Join-Path $root $file) } diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 index c0993787c..65001da27 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/localbox.health.tests.ps1 @@ -7,6 +7,7 @@ BeforeAll { Describe 'Shared LocalBox control plane' { It 'has all mandatory manifest fields' { + $LocalBox.AksPreparationSkipped | Should -Not -BeTrue -Because 'a partial preparation is not full LocalBox readiness' foreach ($field in @('SubscriptionId', 'ResourceGroupName', 'ClusterId', 'BridgeId', 'ExtensionId', 'CustomLocationId', 'StorageId', 'ImageId', 'AksId', 'AksInstanceId', 'AksAdminGroupObjectId', 'ImageExpected', 'AksExpected', 'NodeNames', 'StorageSizeGB')) { $LocalBox[$field] | Should -Not -BeNullOrEmpty -Because "$field is part of the expected inventory" } @@ -79,9 +80,13 @@ Describe 'LocalBox runtime health' -Skip:($Mode -ne 'Full') { $state.Free | Should -BeGreaterOrEqual 100GB } } - It 'allows the supplied AKS group-member kubeconfig to query healthy nodes and workloads' { - $minimum = 1 + [int]$LocalBox.AksExpected.properties.agentPoolProfiles[0].count - Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { Test-SovereignKubernetes $LocalBox.Kubeconfig $minimum } + It 'allows the supplied AKS kubeconfig to query the expected ready workers, control plane and system workloads' { + $workers = [int]$LocalBox.AksExpected.properties.agentPoolProfiles[0].count + $controlPlane = [int]$LocalBox.AksExpected.properties.controlPlane.count + Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { + Assert-SovereignLocalNodeCount (Invoke-SovereignKubectl $LocalBox.Kubeconfig @('get', 'nodes')) $workers $controlPlane + Test-SovereignKubernetes $LocalBox.Kubeconfig ($workers + $controlPlane) + } } It 'can resolve Azure and reach configured gateways from the nested environment' { $NodeCredential | Should -Not -BeNullOrEmpty 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 b0a138158..43a256f20 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 @@ -19,6 +19,32 @@ Describe 'Lab cost-control tags' { } Describe 'LocalBox input validation' { + It 'defaults to three AKS worker nodes' { + . "$PSScriptRoot/../prepare-localbox.ps1" + $NodeCount | Should -Be 3 + } + It 'allows an explicit AKS worker-count override' { + . "$PSScriptRoot/../prepare-localbox.ps1" -NodeCount 1 + $NodeCount | Should -Be 1 + } + It 'uses the image resource name shown in the walkthrough' { + . "$PSScriptRoot/../prepare-localbox.ps1" + $ImageName | Should -Be '2025-datacenter-azure-edition-smalldisk-01' + } + It 'allows an explicit image resource-name override' { + . "$PSScriptRoot/../prepare-localbox.ps1" -ImageName 'existing-lab-image' + $ImageName | Should -Be 'existing-lab-image' + } + It 'does not prompt or fabricate a group ID when AKS is explicitly skipped' { + Mock Read-Host { throw 'Unexpected prompt' } + Read-LocalBoxGroupId -SkipAks | Should -BeNullOrEmpty + Should -Invoke Read-Host -Times 0 + } + It 'fails on an empty group response instead of repeatedly prompting unattended runs' { + Mock Read-Host { '' } + { Read-LocalBoxGroupId } | Should -Throw + Should -Invoke Read-Host -Times 1 + } It 'accepts the AKS node range while reserving control plane and VIP addresses' { { Test-LocalBoxAddressPool '10.10.0.0/24' '10.10.0.101' '10.10.0.199' '10.10.0.1' @('10.10.0.5', '10.10.0.10', '10.10.0.100') } | Should -Not -Throw } @@ -44,6 +70,15 @@ Describe 'LocalBox input validation' { } } +Describe 'AKS Local resource API selection' { + It 'specifies the API version for nested AKS Local node pools' { + Mock Invoke-LocalBoxAz { @{} } + $identifier = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.Kubernetes/connectedClusters/localbox-aks/providers/Microsoft.HybridContainerService/provisionedClusterInstances/default/agentPools/nodepool1' + Get-LocalBoxResource $identifier + Should -Invoke Invoke-LocalBoxAz -Times 1 -ParameterFilter { $Arguments -contains '--api-version' -and $Arguments -contains '2024-01-01' } + } +} + Describe 'Resource reconciliation' { BeforeEach { $resourceId = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/logicalNetworks/vm-net' @@ -93,6 +128,57 @@ Describe 'Resource reconciliation' { } } +Describe 'CLI process cleanup' { + BeforeEach { + Mock Get-LocalBoxAzInvocation { @{ Executable = 'az'; Prefix = @() } } + } + It 'cleans up read-only CLI jobs even during WhatIf' { + Mock Start-Job { 1 } + Mock Wait-Job { 1 } + Mock Receive-Job { @{ name = 'read-only-result' } } + Mock Remove-Job {} + $WhatIfPreference = $true + (Invoke-LocalBoxAz @('account', 'show')).name | Should -Be 'read-only-result' + Should -Invoke Remove-Job -Times 1 -ParameterFilter { -not $WhatIf -and -not $Confirm } + } + It 'propagates a failed CLI job and still cleans it up' { + Mock Start-Job { 1 } + Mock Wait-Job { 1 } + Mock Receive-Job { throw 'Azure CLI returned a nonzero exit code' } + Mock Remove-Job {} + { Invoke-LocalBoxAz @('resource', 'list') } | Should -Throw '*nonzero*' + Should -Invoke Remove-Job -Times 1 + } + It 'bounds a stalled CLI job without claiming the Azure operation was canceled' { + Mock Start-Job { 1 } + Mock Wait-Job { $null } + Mock Receive-Job {} + Mock Remove-Job {} + { Invoke-LocalBoxAz @('resource', 'create') -TimeoutSeconds 1 } | Should -Throw '*may still be running*' + Should -Invoke Receive-Job -Times 0 + Should -Invoke Remove-Job -Times 1 + } +} + +Describe 'Windows Azure CLI argument transport' { + It 'bypasses the batch wrapper using its bundled Python' { + Mock Test-Path { $true } + $command = Join-Path $TestDrive 'CLI2/wbin/az.cmd' + $invocation = Get-LocalBoxAzInvocation -CommandPath $command + $invocation.Executable | Should -Be ([IO.Path]::GetFullPath((Join-Path $TestDrive 'CLI2/python.exe'))) + $invocation.Prefix | Should -Be @('-IBm', 'azure.cli') + } + It 'rejects a batch installation without its bundled Python' { + Mock Test-Path { $false } + { Get-LocalBoxAzInvocation -CommandPath (Join-Path $TestDrive 'az.cmd') } | Should -Throw '*bundled Python*' + } + It 'leaves non-batch CLI executables unchanged' { + $invocation = Get-LocalBoxAzInvocation -CommandPath '/usr/bin/az' + $invocation.Executable | Should -Be '/usr/bin/az' + $invocation.Prefix.Count | Should -Be 0 + } +} + Describe 'Storage removal guard' { BeforeEach { $state = @{ NodesUp = $true; PoolHealth = 'Healthy'; StorageJobs = 0; SecondaryFiles = @(); UnhealthyPhysicalDisks = 0 } @@ -120,7 +206,49 @@ Describe 'Storage removal guard' { } } +Describe 'Generated Azure Local storage names' { + BeforeEach { + Mock Get-LocalBoxResource { @{ id = $Id; extendedLocation = @{ name = '/custom/jumpstart' }; properties = @{ provisioningState = 'Succeeded' } } } + $resources = @(@{ name = 'UserStorage1-ae41ccf444cc4b64a24de6d2a4b69e07'; type = 'Microsoft.AzureStackHCI/storageContainers'; id = '/storage/primary' }) + } + It 'resolves a generated storage name to its real resource ID' { + (Resolve-LocalBoxStoragePath $resources UserStorage1 '/custom/jumpstart').id | Should -Be '/storage/primary' + } + It 'refuses ambiguous storage paths' { + $resources += @{ name = 'UserStorage1'; type = 'Microsoft.AzureStackHCI/storageContainers'; id = '/storage/another' } + { Resolve-LocalBoxStoragePath $resources UserStorage1 '/custom/jumpstart' } | Should -Throw '*exactly one*' + } + It 'refuses storage from another custom location' { + { Resolve-LocalBoxStoragePath $resources UserStorage1 '/custom/other' } | Should -Throw '*different custom location*' + } + It 'allows an absent secondary path only when explicitly requested' { + Resolve-LocalBoxStoragePath $resources UserStorage2 '/custom/jumpstart' -AllowMissing | Should -BeNullOrEmpty + { Resolve-LocalBoxStoragePath $resources UserStorage2 '/custom/jumpstart' } | Should -Throw '*exactly one*' + } +} + Describe 'Health checks do not report false readiness' { + It 'accepts one Ready control-plane node and three Ready workers' { + $control = @{ metadata = @{ labels = @{ 'node-role.kubernetes.io/control-plane' = '' } }; status = @{ conditions = @(@{ type = 'Ready'; status = 'True' }) } } + $worker = @{ metadata = @{ labels = @{} }; status = @{ conditions = @(@{ type = 'Ready'; status = 'True' }) } } + { Assert-SovereignLocalNodeCount @{ items = @($control, $worker, $worker, $worker) } 3 1 } | Should -Not -Throw + } + It 'rejects four Ready nodes with the wrong roles' { + $worker = @{ metadata = @{ labels = @{} }; status = @{ conditions = @(@{ type = 'Ready'; status = 'True' }) } } + { Assert-SovereignLocalNodeCount @{ items = @($worker, $worker, $worker, $worker) } 3 1 } | Should -Throw '*found 0 and 4*' + } + It 'rejects a pending worker even when the node count matches' { + $control = @{ metadata = @{ labels = @{ 'node-role.kubernetes.io/control-plane' = '' } }; status = @{ conditions = @(@{ type = 'Ready'; status = 'True' }) } } + $worker = @{ metadata = @{ labels = @{} }; status = @{ conditions = @(@{ type = 'Ready'; status = 'False' }) } } + { Assert-SovereignLocalNodeCount @{ items = @($control, $worker, $worker, $worker) } 3 1 } | Should -Throw '*not Ready*' + } + It 'accepts a fork and branch for the health suite download' { + . "$PSScriptRoot/../test-sovereign-cloud.ps1" -GitHubRepository 'janegilring/MicroHack' -GitHubRef 'sov-cloud-localbox-post-automation' + $GitHubRepository | Should -Be 'janegilring/MicroHack' + $GitHubRef | Should -Be 'sov-cloud-localbox-post-automation' + $source = Get-Content "$PSScriptRoot/../test-sovereign-cloud.ps1" -Raw + $source | Should -Match 'https://raw.githubusercontent.com/\$GitHubRepository/\$GitHubRef/' + } It 'rejects empty node collections and NotReady nodes' { { Assert-SovereignNodes @{ items = @() } 1 } | Should -Throw { Assert-SovereignNodes @{ items = @(@{ metadata = @{ name = 'worker' }; status = @{ conditions = @(@{ type = 'Ready'; status = 'False' }) } }) } 1 } | Should -Throw '*not Ready*' @@ -181,7 +309,7 @@ Describe 'Storage execution safety' { Mock New-VHD {} Mock Add-VMHardDiskDrive {} Mock Invoke-Command {} - Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 679GB } } + Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; VolumeHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 679GB } } } It 'does not create, attach or resize anything during WhatIf' { Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -WhatIf @@ -192,10 +320,37 @@ Describe 'Storage execution safety' { It 'does not duplicate disks or grow an already sized virtual disk on rerun' { Mock Test-Path { $true } Mock Get-VMHardDiskDrive { @(@{ Path = Join-Path $TestDrive "$VMName-microhack-s2d.vhdx" }) } - Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 1TB } } + Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; VolumeHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 1TB } } Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -Confirm:$false Should -Invoke New-VHD -Times 0 Should -Invoke Add-VMHardDiskDrive -Times 0 Should -Invoke Invoke-Command -Times 0 -ParameterFilter { $ScriptBlock.ToString() -match 'Resize-VirtualDisk' } } +} + +Describe 'Storage convergence' { + It 'ignores retained completed storage jobs' { + Mock Invoke-Command { @{ StorageJobs = 0; StorageJobStates = @('Completed', 'Completed') } } + $credential = [pscredential]::new('test-user', [Security.SecureString]::new()) + (Get-LocalBoxNodeState 'node' $credential).StorageJobs | Should -Be 0 + } + It 'does not ignore active, failed or suspended storage jobs' { + Mock Invoke-Command { @{ StorageJobs = 0; StorageJobStates = @('Completed', 'Running', 'Exception', 'Suspended') } } + $credential = [pscredential]::new('test-user', [Security.SecureString]::new()) + (Get-LocalBoxNodeState 'node' $credential).StorageJobs | Should -Be 3 + } + It 'waits for post-attachment storage jobs without ignoring health' { + $script:storagePoll = 0 + Mock Start-Sleep {} + Mock Get-LocalBoxNodeState { + $script:storagePoll++ + @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; VolumeHealth = 'Healthy'; NodesUp = $true; UnhealthyPhysicalDisks = 0; StorageJobs = $(if ($script:storagePoll -eq 1) { 1 } else { 0 }) } + } + (Wait-LocalBoxStorageReady 'node').StorageJobs | Should -Be 0 + Should -Invoke Start-Sleep -Times 1 + } + It 'fails when storage never becomes healthy' { + Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Warning'; StorageJobs = 1 } } + { Wait-LocalBoxStorageReady 'node' -TimeoutSeconds 0 } | Should -Throw '*Timed out*' + } } \ No newline at end of file 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 12f1e1e1d..a0b2a7a6a 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 @@ -14,6 +14,8 @@ Full Kubernetes checks take dedicated kubeconfigs that already authenticate to t Download [test-sovereign-cloud.ps1](../test-sovereign-cloud.ps1) from the same trusted ref as preparation. The runner's `-DownloadTests` downloads the helper and two suites to a temporary directory; `-GitHubRef` must identify that **same ref**, preferably a commit SHA. Downloaded scripts execute under your privileges, so review and pin them. +For a fork, also specify `-GitHubRepository 'owner/MicroHack'`. For example, testing this branch uses `-GitHubRepository 'janegilring/MicroHack' -GitHubRef 'sov-cloud-localbox-post-automation'`. The default repository remains `microsoft/MicroHack`. + ```powershell $ref = 'main' $base = "https://raw.githubusercontent.com/microsoft/MicroHack/$ref/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources" @@ -36,6 +38,31 @@ $nodeCredential = Get-Credential -Message 'Nested Azure Local Windows administra Checks cover the Client and bootstrap, cluster/Arc bridge/custom location/AKS extension, supporting Azure resources, image download/storage placement, exact network settings, expected AKS topology/admin group, nested-node/storage health and capacity, Kubernetes node readiness and system pods/controllers. Gateway/DNS/HTTPS probes run from a nested node; these do not prove connectivity from a participant VM that has not yet been created. +## Rerun from a local copy + +In elevated PowerShell 7 on LocalBox-Client, change to your local resources directory containing the runner, preparation script and `tests` folder. Use the manifest written by preparation (`-ManifestPath`, default `C:\LocalBox\sovereign-localbox.json`) and an existing authenticated kubeconfig. Keep all scripts from the same reviewed revision; do not overwrite unpublished local fixes with an older download. + +The commands below retain your existing Azure CLI authentication. Confirm that it has access to the selected lab resources as described in [Tooling and authentication](#tooling-and-authentication). + +```powershell +$manifestPath = Read-Host 'Path to the manifest written by prepare-localbox.ps1' +$kubeconfigPath = Read-Host 'Path to your authenticated AKS kubeconfig' +$outputDirectory = Join-Path (Get-Location) 'health-results' +Import-Module Pester -RequiredVersion 5.7.1 +Invoke-Pester .\tests\prepare-localbox.tests.ps1 -Output Detailed + +$nodeCredential = Get-Credential -Message 'Administrator of the nested Azure Local nodes' +.\test-sovereign-cloud.ps1 -Scope LocalBox -Mode Full ` + -LocalBoxManifestPath $manifestPath ` + -LocalBoxKubeconfig $kubeconfigPath ` + -NodeCredential $nodeCredential -OutputDirectory $outputDirectory +Get-Content (Join-Path $outputDirectory 'health.json') +``` + +Use a group-member kubeconfig to exercise Entra-based access. An explicitly supplied cluster-admin certificate can verify Kubernetes runtime health, but **not Entra group-member sign-in**. Restrict kubeconfig file access and never publish its contents. The health suite only reads the supplied credential; it does not create a kubeconfig, retrieve credentials or grant roles. + +After an intentional scale operation, the manifest must contain the new expected worker count. Preparation defaults to one control-plane node and three workers. Use `-NodeCount` to override the worker count; preparation does not silently scale an existing cluster whose configuration differs. + ## Participant lab inventory Create a local JSON inventory with one entry for every lab to validate. Use the actual successful `sovereign-lab-*` deployment name, not the subscription-level RBAC deployment name. Paths must resolve on the test machine. From 644d5d565d648c16be7ef24393bc88cf78a48391 Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Sun, 20 Sep 2026 18:04:27 +0000 Subject: [PATCH 3/5] Automate LocalBox CLI extension setup including dry runs --- .../resources/localbox/readme.md | 10 +-- .../resources/prepare-localbox.ps1 | 32 ++++++++- .../tests/prepare-localbox.tests.ps1 | 65 +++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) 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 a9b752d34..2bf370db8 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 @@ -4,8 +4,8 @@ Run [prepare-localbox.ps1](../prepare-localbox.ps1) **inside LocalBox-Client**, ## Prerequisites -- Elevated PowerShell 7 on `LocalBox-Client`; installed Azure CLI with `stack-hci-vm`, `customlocation` and `aksarc` extensions. Install missing extensions explicitly before running; the script neither installs nor upgrades tooling. Record the versions used for your event. -- Do not assume a new Jumpstart Client includes these CLI extensions. Check `az extension list` in the profile you will use. Keep any existing extensions; install only missing ones. Health checks additionally require Pester 5.7.1 or later within major version 5; an installed Pester 6 is not a substitute for the runner's supported version. +- 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. +- Extensions are installed in your persistent CLI extension directory (`AZURE_EXTENSION_DIR`, if set), not the temporary authentication profile. The script does not install or upgrade the Azure CLI itself. Health checks additionally require Pester 5.7.1 or later within major version 5; an installed Pester 6 is not a substitute for the runner's supported version. - Healthy Azure Local nodes, storage, Arc Resource Bridge, `jumpstart` custom location and `hybridaksextension`. - The Client VM's system-assigned managed identity and its existing LocalBox resource-group permissions. The script uses a temporary isolated CLI login and restores the caller's profile. No subscription-wide or Graph permissions are added. - An existing Entra AKS admin-group object ID. Hosted events obtain this from the Console owner; [group provisioning is a documented dependency](../hosted-events/readme.md#console-group-dependency). Group membership is not validated through Graph by this script. @@ -13,6 +13,8 @@ Run [prepare-localbox.ps1](../prepare-localbox.ps1) **inside LocalBox-Client**, - `Microsoft.EdgeMarketplace` registered by the subscription owner. The Marketplace image workflow also requires the Azure Connected Machine Resource Manager role for the `Microsoft.AzureStackHCI` resource-provider identity on the image resource group; ask the deployment owner to verify this prerequisite. No automatic privilege escalation is attempted. - Verified DHCP exclusions/static reservations for both pools, sufficient backing `V:` capacity and event budget. Dynamic disks do not add physical backing capacity. +For the standard Jumpstart configuration, the nested-node administrator is `jumpstart\Administrator`. The Client VM's `arcdemo` account is not the nested-node administrator. Use the nested domain's credentials (adjust the domain if customized), not a password reset solely on the Client VM. + ## Download and run Download files, inspect them, then execute. Do not pipe downloaded content into `Invoke-Expression`. The main URL works after publication; use your published branch/ref while validating a change. An immutable commit is preferable for repeatable events. @@ -20,7 +22,7 @@ Download files, inspect them, then execute. Do not pipe downloaded content into ```powershell $base = 'https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources' Invoke-WebRequest "$base/prepare-localbox.ps1" -OutFile './prepare-localbox.ps1' -$nodeCredential = Get-Credential -Message 'Nested Azure Local Windows administrator' +$nodeCredential = Get-Credential -UserName 'jumpstart\Administrator' -Message 'Nested Azure Local Windows administrator' ./prepare-localbox.ps1 -NodeCredential $nodeCredential -AddressReservationsConfirmed -WhatIf ./prepare-localbox.ps1 -NodeCredential $nodeCredential -AddressReservationsConfirmed ``` @@ -29,7 +31,7 @@ The script prompts for the Entra group object ID, or accepts `-AksAdminGroupObje If the Console-owned group is not available yet, `-SkipAks` explicitly prepares only storage, the VM image and both networks. It does not fabricate a group or grant anyone cluster access. The manifest records this as a partial run, and full health checks fail until preparation is rerun without `-SkipAks` using a valid admin-group ID. -`-WhatIf` performs authenticated reads and local validation, but no Azure/Hyper-V/storage provisioning. It can create and remove a temporary local CLI profile. It does not establish that later resource creation will succeed. +`-WhatIf` installs missing CLI extensions as a deliberate local-tooling exception, then performs authenticated reads and validation. It does not provision Azure resources or change Hyper-V or storage. It can create and remove a temporary local CLI profile. It does not establish that later resource creation will succeed. ## Prepared resources diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 index 33bc87069..f7c971500 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 @@ -5,6 +5,9 @@ Prepares an already deployed Jumpstart LocalBox for the Sovereign Cloud MicroHac .DESCRIPTION Run elevated on LocalBox-Client. Azure operations use its managed identity; nested Windows operations use a separately supplied PSCredential. +Missing required Azure CLI extensions are installed without upgrading existing +versions, including during WhatIf. Azure, Hyper-V and storage changes remain +simulated during WhatIf. Dot-source this file to load its functions without running provisioning. #> [CmdletBinding(SupportsShouldProcess)] @@ -128,6 +131,29 @@ function Invoke-LocalBoxAz { finally { Remove-Job $job -Force -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false } } +function Initialize-LocalBoxCliExtension { + [CmdletBinding(SupportsShouldProcess)] + param() + $installed = @(Invoke-LocalBoxAz @('extension', 'list')) + $ready = $true + foreach ($extension in @('stack-hci-vm', 'customlocation', 'aksarc')) { + if ($extension -notin $installed.name) { + if (-not $PSCmdlet.ShouldProcess($extension, 'Install missing Azure CLI extension')) { + $ready = $false + continue + } + Write-Host "Installing missing Azure CLI extension $extension..." + Invoke-LocalBoxAz @('extension', 'add', '--name', $extension) -TimeoutSeconds 900 | Out-Null + } + $details = Invoke-LocalBoxAz @('extension', 'show', '--name', $extension) + if ($details.name -ne $extension -or [string]::IsNullOrWhiteSpace($details.version)) { + throw "Azure CLI extension $extension could not be verified. Preparation has stopped." + } + Write-Host "$extension $($details.version)" + } + return $ready +} + function Get-LocalBoxResource { param([string]$Id) $arguments = @('resource', 'show', '--ids', $Id) @@ -392,9 +418,9 @@ function Invoke-LocalBoxPreparation { Write-Host 'Checking managed identity, Azure resources and CLI prerequisites...' Invoke-LocalBoxAz @('login', '--identity') | Out-Null Invoke-LocalBoxAz @('account', 'set', '--subscription', $Settings.SubscriptionId) | Out-Null - foreach ($extension in @('stack-hci-vm', 'customlocation', 'aksarc')) { - $version = Invoke-LocalBoxAz @('extension', 'show', '--name', $extension) - Write-Host "$extension $($version.version)" + if (-not (Initialize-LocalBoxCliExtension -WhatIf:$false)) { + Write-Warning 'Required CLI extension installation was declined. Remaining preparation checks were not run.' + return } $scope = "/subscriptions/$($Settings.SubscriptionId)/resourceGroups/$($Settings.ResourceGroupName)" $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--resource-group', $Settings.ResourceGroupName)) 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 43a256f20..2bdd5d73d 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 @@ -160,6 +160,71 @@ Describe 'CLI process cleanup' { } } +Describe 'Missing CLI extension installation' { + BeforeEach { + Mock Invoke-LocalBoxAz { + if ($Arguments[1] -eq 'list') { return @(@{ name = 'customlocation'; version = '0.1.4' }) } + if ($Arguments[1] -eq 'show') { return @{ name = $Arguments[3]; version = '1.0.0' } } + } + } + It 'installs only missing extensions and verifies all required versions' { + Initialize-LocalBoxCliExtension | Should -BeTrue + Should -Invoke Invoke-LocalBoxAz -Times 2 -Exactly -ParameterFilter { $Arguments[1] -eq 'add' } + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { $Arguments[1] -eq 'add' -and $Arguments[3] -eq 'stack-hci-vm' } + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { $Arguments[1] -eq 'add' -and $Arguments[3] -eq 'aksarc' } + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'add' -and $Arguments[3] -eq 'customlocation' } + Should -Invoke Invoke-LocalBoxAz -Times 3 -Exactly -ParameterFilter { $Arguments[1] -eq 'show' } + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains '--upgrade' -or $Arguments[1] -eq 'update' } + } + It 'leaves an already prepared Client unchanged' { + Mock Invoke-LocalBoxAz { @(@{ name = 'stack-hci-vm' }, @{ name = 'customlocation' }, @{ name = 'aksarc' }) } -ParameterFilter { $Arguments[1] -eq 'list' } + Initialize-LocalBoxCliExtension | Should -BeTrue + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'add' } + } + It 'installs all required extensions on a fresh Client' { + Mock Invoke-LocalBoxAz { @() } -ParameterFilter { $Arguments[1] -eq 'list' } + Initialize-LocalBoxCliExtension | Should -BeTrue + Should -Invoke Invoke-LocalBoxAz -Times 3 -Exactly -ParameterFilter { $Arguments[1] -eq 'add' -and $TimeoutSeconds -eq 900 } + } + It 'honors helper-level WhatIf when the preparation override is not applied' { + Initialize-LocalBoxCliExtension -WhatIf | Should -BeFalse + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'add' } + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'show' -and $Arguments[3] -in @('stack-hci-vm', 'aksarc') } + } + It 'installs missing prerequisites under inherited WhatIf without allowing resource creation' { + $WhatIfPreference = $true + Initialize-LocalBoxCliExtension -WhatIf:$false | Should -BeTrue + $WhatIfPreference | Should -BeTrue + Sync-LocalBoxResource -Id '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/logicalNetworks/test-net' ` + -Expected @{} -CreateArguments @('resource', 'create') + Should -Invoke Invoke-LocalBoxAz -Times 2 -Exactly -ParameterFilter { $Arguments[0] -eq 'extension' -and $Arguments[1] -eq 'add' } + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[0] -eq 'resource' -and $Arguments[1] -eq 'create' } + } + It 'limits the preparation WhatIf exception to the extension-setup call' { + ${function:Invoke-LocalBoxPreparation}.ToString() | Should -Match 'Initialize-LocalBoxCliExtension -WhatIf:\$false' + ${function:Invoke-LocalBoxPreparation}.ToString() | Should -Not -Match '\$WhatIfPreference\s*=\s*\$false' + } + It 'allows a complete dry run when all required extensions already exist' { + Mock Invoke-LocalBoxAz { @(@{ name = 'stack-hci-vm' }, @{ name = 'customlocation' }, @{ name = 'aksarc' }) } -ParameterFilter { $Arguments[1] -eq 'list' } + Initialize-LocalBoxCliExtension -WhatIf | Should -BeTrue + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'add' } + } + It 'propagates discovery errors rather than assuming extensions are missing' { + Mock Invoke-LocalBoxAz { throw 'Extension directory cannot be read' } -ParameterFilter { $Arguments[1] -eq 'list' } + { Initialize-LocalBoxCliExtension } | Should -Throw '*cannot be read*' + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'add' } + } + It 'stops after a failed installation' { + Mock Invoke-LocalBoxAz { throw 'Download failed' } -ParameterFilter { $Arguments[1] -eq 'add' } + { Initialize-LocalBoxCliExtension } | Should -Throw '*Download failed*' + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[1] -eq 'show' } + } + It 'rejects an installation that cannot be verified' { + Mock Invoke-LocalBoxAz { @{ name = 'stack-hci-vm' } } -ParameterFilter { $Arguments[1] -eq 'show' } + { Initialize-LocalBoxCliExtension } | Should -Throw '*could not be verified*' + } +} + Describe 'Windows Azure CLI argument transport' { It 'bypasses the batch wrapper using its bundled Python' { Mock Test-Path { $true } From 2ecdc40bd23162418f0a23aa526c763b41ab18ca Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Sun, 20 Sep 2026 19:30:41 +0000 Subject: [PATCH 4/5] Improve LocalBox credential handling and image preflight --- .../labautomation/deploy-localbox.ps1 | 29 ++- .../labautomation/localbox-credentials.ps1 | 41 +++ .../labautomation/shared-deploy-lab.ps1 | 6 + .../resources/hosted-events/readme.md | 24 +- .../resources/localbox/readme.md | 15 +- .../resources/prepare-localbox.ps1 | 38 ++- .../01_Sovereign_Cloud/resources/readme.md | 2 +- .../tests/prepare-localbox.tests.ps1 | 246 ++++++++++++++++++ .../resources/tests/readme.md | 10 +- 9 files changed, 385 insertions(+), 26 deletions(-) create mode 100644 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/localbox-credentials.ps1 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 index 623fccb41..a80199f84 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-localbox.ps1 @@ -15,6 +15,10 @@ The Azure region for the LocalBox Azure resources. Optional host administrator password. For unattended deployment, prefer the LOCALBOX_ADMIN_PASSWORD environment variable so the secret is not in the process list. A random password is generated when neither value is supplied. +.PARAMETER UseConsoleCredentials +Use the Console's stable LocalBox password instead of a supplied or random password. +It is not published as a participant credential. Coach/event-lead-only retrieval +requires a supported mechanism from the Console owner. .PARAMETER GithubRef The azure_arc branch, tag, or commit used for both Bicep and runtime artifacts. .PARAMETER AzureLocalResourceProviderObjectId @@ -36,6 +40,8 @@ param( [string]$WindowsAdminPassword, + [switch]$UseConsoleCredentials, + [bool]$DeployBastion = $true, [ValidateSet('Standard_E32s_v5', 'Standard_E32s_v6')] @@ -207,7 +213,16 @@ if ([string]::IsNullOrWhiteSpace($AzureLocalResourceProviderObjectId)) { } $generatedPassword = $false -if ([string]::IsNullOrEmpty($WindowsAdminPassword) -and -not [string]::IsNullOrEmpty($env:LOCALBOX_ADMIN_PASSWORD)) { +if ($UseConsoleCredentials) { + if (-not [string]::IsNullOrEmpty($WindowsAdminPassword) -or -not [string]::IsNullOrEmpty($env:LOCALBOX_ADMIN_PASSWORD)) { + throw 'UseConsoleCredentials cannot be combined with WindowsAdminPassword or LOCALBOX_ADMIN_PASSWORD.' + } + $WindowsAdminPassword = New-MhhStablePassword -Purpose 'localbox-admin-v1' -Length 24 -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($WindowsAdminPassword)) { + throw 'Console did not provide a LocalBox password.' + } +} +elseif ([string]::IsNullOrEmpty($WindowsAdminPassword) -and -not [string]::IsNullOrEmpty($env:LOCALBOX_ADMIN_PASSWORD)) { $WindowsAdminPassword = $env:LOCALBOX_ADMIN_PASSWORD } elseif ([string]::IsNullOrEmpty($WindowsAdminPassword)) { @@ -264,6 +279,12 @@ try { Invoke-WebRequest -Uri "$baseUri/$file" -OutFile (Join-Path $tempDirectory $file) -UseBasicParsing } + $resourceTags = @{ + Project = 'jumpstart_LocalBox' + SecurityControl = 'Ignore' + CostControl = 'Ignore' + } + @{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' contentVersion = '1.0.0.0' @@ -278,11 +299,7 @@ try { azureLocalInstanceLocation = @{ value = $AzureLocalInstanceLocation } deployBastion = @{ value = $DeployBastion } vmSize = @{ value = $VmSize } - tags = @{ value = @{ - Project = 'jumpstart_LocalBox' - SecurityControl = 'Ignore' - CostControl = 'Ignore' - } } + tags = @{ value = $resourceTags } governResourceTags = @{ value = $false } } } | ConvertTo-Json -Depth 10 | Set-Content -Path $parametersFile -Encoding utf8NoBOM 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 new file mode 100644 index 000000000..bd7a01f24 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/localbox-credentials.ps1 @@ -0,0 +1,41 @@ +function Get-LocalBoxConsoleGroupCredential { + [CmdletBinding()] + param() + + $group = Get-MhhDefaultLabGroup -ErrorAction Stop + $objectId = [guid]::Empty + if (-not [guid]::TryParse([string]$group.ObjectId, [ref]$objectId) -or $objectId -eq [guid]::Empty -or + [string]::IsNullOrWhiteSpace($group.GroupName) -or [string]::IsNullOrWhiteSpace($group.DisplayName)) { + throw 'Console did not return a valid default lab group. Complete Entra user/group creation before shared deployment.' + } + + @{ HackboxCredential = @{ name = 'Lab Group ObjectId'; value = [string]$group.ObjectId; note = 'Use as AksAdminGroupObjectId for LocalBox preparation' } } + @{ HackboxCredential = @{ name = 'Lab Group GroupName'; value = $group.GroupName; note = '' } } + @{ HackboxCredential = @{ name = 'Lab Group DisplayName'; value = $group.DisplayName; note = '' } } +} + +function Wait-LocalBoxDeployment { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$DeploymentName, + [ValidateRange(1, 21600)][int]$TimeoutSeconds = 21600 + ) + + $deadline = (Get-Date -AsUTC).AddSeconds($TimeoutSeconds) + do { + Update-MhhToken | Out-Null + $deployment = Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -Name $DeploymentName -ErrorAction Stop + if ($deployment.ProvisioningState -eq 'Succeeded') { + break + } + if ($deployment.ProvisioningState -in @('Failed', 'Canceled')) { + 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." + } + Write-Host "Waiting for LocalBox ARM deployment '$DeploymentName' ($($deployment.ProvisioningState))..." + Start-Sleep -Seconds 30 + } while ($true) +} \ No newline at end of file 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 ddd8707ce..bd985c5e7 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 @@ -19,6 +19,9 @@ param( ) . (Join-Path $PSScriptRoot 'quota-helpers.ps1') +. (Join-Path $PSScriptRoot 'localbox-credentials.ps1') + +Get-LocalBoxConsoleGroupCredential $azureLocalResourceProviderAppId = '1412d89f-b8a8-4111-b4fd-e82905cbd85d' Update-MhhToken | Out-Null @@ -210,6 +213,7 @@ else { -Location $localBoxLocation ` -AzureLocalResourceProviderObjectId $azureLocalResourceProviderObjectIds[0] ` -AzureLocalInstanceLocation 'australiaeast' ` + -UseConsoleCredentials ` -NoWait if (-not $localBoxDeployment -or $localBoxDeployment.ProvisioningState -ne 'Submitted') { @@ -260,6 +264,8 @@ foreach ($participantObjectId in $AllowedEntraUserIds) { } } +Wait-LocalBoxDeployment -ResourceGroupName $localBoxResourceGroupName -DeploymentName $localBoxDeployment.DeploymentName + @{ 'HackboxCredential' = @{ name = 'Shared LocalBox Resource Group' 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 186ca6df1..f8049a6b7 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 @@ -16,12 +16,12 @@ When automatic provisioning is not scheduled, use the Console's **Create Users** ## Complete LocalBox preparation -The shared hook submits `localbox-*` asynchronously in `rg-localbox-shared`. 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. +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. 1. Inspect the Azure deployment, Client bootstrap logs and Azure Local/Arc status. Resolve failures before proceeding. -2. Connect to `LocalBox-Client` through its Bastion. If needed, an authorized facilitator uses **Help > Reset password** to set a known password for the `arcdemo` host account. Resetting the host password does not change the nested-node Windows credentials. -3. Obtain the AKS admin-group object ID and 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. Azure authentication uses the Client VM's managed identity; nested Windows storage operations require their own credentials. +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. 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. 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. Complete the [participant VM readiness exercise](../localbox/manual-preparation.md#step-6-test-the-environment), including guest management, Defender and Update Manager. Subscription-wide paid-plan changes require the authorized owner and approved budget. @@ -35,11 +35,23 @@ Deployment automation also applies `CostControl=Ignore` to the LocalBox resource Verify that the hosted governance system honors the tag. It does not disable independent VM shutdown schedules, restart already stopped VMs or prevent the Console's scheduled teardown. Keep monitoring costs and retain the event end time and cleanup process. +### LocalBox credentials + +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. + +**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. + +Treat the installed configuration as a secret-bearing file: do not print it, include it in screenshots or attach it to support requests. Limit Client administrator access to authorized facilitators. If its password has been exposed, coordinate rotation with the LocalBox administrator; changing only `arcdemo` is not sufficient to rotate the nested credential. + +If an earlier automation version already published an administrator password, removing its output does not erase the stored Console credential. Ask the Console owner to remove the entry and coordinate credential rotation with the LocalBox administrator. + ### Console group dependency -The [platform contract](../../../../../99-MicroHack-Template/labautomation/README.md#groups-supported-values) currently documents `groups` as licensing activation (`GHCPUsers`, `M365-E5-Users`), not arbitrary group creation. Do not add an invented group name or schema field. +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 Console owner must provide the supported mechanism to create/reuse a security group per shared LocalBox scope, maintain intended temporary Azure users/coaches and late registrations, expose its object ID, and remove the group at teardown. If the supported hook returns the ID, it can publish a non-reserved `LocalBox AKS Admin Group ID` credential. Until that contract is available, obtain the ID through the Console owner. The preparation script prompts for it but does not create groups, query Graph or grant directory privileges. +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. ## Run and close the event 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 2bf370db8..3661ac208 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 @@ -8,13 +8,15 @@ Run [prepare-localbox.ps1](../prepare-localbox.ps1) **inside LocalBox-Client**, - Extensions are installed in your persistent CLI extension directory (`AZURE_EXTENSION_DIR`, if set), not the temporary authentication profile. The script does not install or upgrade the Azure CLI itself. Health checks additionally require Pester 5.7.1 or later within major version 5; an installed Pester 6 is not a substitute for the runner's supported version. - Healthy Azure Local nodes, storage, Arc Resource Bridge, `jumpstart` custom location and `hybridaksextension`. - The Client VM's system-assigned managed identity and its existing LocalBox resource-group permissions. The script uses a temporary isolated CLI login and restores the caller's profile. No subscription-wide or Graph permissions are added. -- An existing Entra AKS admin-group object ID. Hosted events obtain this from the Console owner; [group provisioning is a documented dependency](../hosted-events/readme.md#console-group-dependency). Group membership is not validated through Graph by this script. -- Windows administrator credentials for `AzLHOST1`/`AzLHOST2`. Enter these in the local `Get-Credential` prompt. Azure managed identity cannot authenticate Windows PowerShell Direct inside nested nodes. +- An existing Entra AKS admin-group object ID. Hosted events use **Lab Group ObjectId** from the Console's **Credentials** tab, published by [shared automation](../hosted-events/readme.md#console-group-dependency). Group membership is not validated through Graph by this script. +- The installed Jumpstart configuration referenced by `$env:LocalBoxConfigFile` (or `-ConfigPath`), containing `SDNDomainFQDN` and `SDNAdminPassword`. Preparation constructs the nested administrator credential locally without printing it. Supply `-NodeCredential` to override it if the nested password/account has changed. Azure managed identity cannot authenticate Windows PowerShell Direct inside nested nodes. - `Microsoft.EdgeMarketplace` registered by the subscription owner. The Marketplace image workflow also requires the Azure Connected Machine Resource Manager role for the `Microsoft.AzureStackHCI` resource-provider identity on the image resource group; ask the deployment owner to verify this prerequisite. No automatic privilege escalation is attempted. - Verified DHCP exclusions/static reservations for both pools, sufficient backing `V:` capacity and event budget. Dynamic disks do not add physical backing capacity. For the standard Jumpstart configuration, the nested-node administrator is `jumpstart\Administrator`. The Client VM's `arcdemo` account is not the nested-node administrator. Use the nested domain's credentials (adjust the domain if customized), not a password reset solely on the Client VM. +LocalBox administrator credentials are not published to participants. If the Client password is unknown, an authorized event lead/coach can reset the Client's `arcdemo` account through the Azure portal, connect via Bastion and open elevated PowerShell 7. A Client-only reset leaves the nested password unchanged; the installed Jumpstart configuration retains the value that preparation uses. Do not open, print, screenshot or share the configuration to obtain the password. Restricted Console retrieval remains optional future work, not a prerequisite for this local workflow; see [LocalBox credentials](../hosted-events/readme.md#localbox-credentials). + ## Download and run Download files, inspect them, then execute. Do not pipe downloaded content into `Invoke-Expression`. The main URL works after publication; use your published branch/ref while validating a change. An immutable commit is preferable for repeatable events. @@ -22,11 +24,12 @@ Download files, inspect them, then execute. Do not pipe downloaded content into ```powershell $base = 'https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources' Invoke-WebRequest "$base/prepare-localbox.ps1" -OutFile './prepare-localbox.ps1' -$nodeCredential = Get-Credential -UserName 'jumpstart\Administrator' -Message 'Nested Azure Local Windows administrator' -./prepare-localbox.ps1 -NodeCredential $nodeCredential -AddressReservationsConfirmed -WhatIf -./prepare-localbox.ps1 -NodeCredential $nodeCredential -AddressReservationsConfirmed +./prepare-localbox.ps1 -AddressReservationsConfirmed -WhatIf +./prepare-localbox.ps1 -AddressReservationsConfirmed ``` +If the nested account was rotated, pass a current `PSCredential` through `-NodeCredential`; do not substitute the reset Client password. Missing configuration credentials produce an actionable error instead of exposing their contents or prompting unexpectedly. + The script prompts for the Entra group object ID, or accepts `-AksAdminGroupObjectId`. Subscription, resource group and configuration path default from Jumpstart's machine environment. Explicit scope values must match this Client's deployment. Run only one preparation session at a time. If the Console-owned group is not available yet, `-SkipAks` explicitly prepares only storage, the VM image and both networks. It does not fabricate a group or grant anyone cluster access. The manifest records this as a partial run, and full health checks fail until preparation is rerun without `-SkipAks` using a valid admin-group ID. @@ -53,6 +56,8 @@ On Windows, the script invokes Azure CLI through its bundled Python executable r Use `-ImageVersion` to pin a Marketplace version, `-KubernetesVersion` for a supported AKS version, and size/count overrides when required. Images can take several hours; `-TimeoutMinutes` defaults to 360 per long operation. A timed-out local command does not cancel an already submitted Azure operation. Inspect status before rerunning. Matching resources are reused; incompatible resources produce an error, never automatic replacement. +If an image was imported manually before preparation under the documented name but on another storage path, preparation stops before disk expansion. The facilitator must review its dependencies; only if the image is confirmed unused should it be removed manually so preparation can import it on `UserStorage1` under the same documented name. Otherwise, resolve placement with the Azure Local administrator before proceeding. Do not change image names or use `-RemoveUserStorage2` to work around this conflict. Automation never deletes or moves an existing image. + ## Optional storage consolidation On a new, empty environment, pass `-RemoveUserStorage2` to request removal of the unused second Azure storage path, CSV and virtual disk. Each destructive operation requires confirmation. The script refuses consolidation when storage is unhealthy/busy, the volume is not empty, its identity is ambiguous, or existing LocalBox workload resources are found. Do this **before** image/AKS creation. Never use confirmation suppression to override a failed safety check. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 index f7c971500..d1a64cf6e 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/prepare-localbox.ps1 @@ -4,7 +4,8 @@ Prepares an already deployed Jumpstart LocalBox for the Sovereign Cloud MicroHack. .DESCRIPTION Run elevated on LocalBox-Client. Azure operations use its managed identity; -nested Windows operations use a separately supplied PSCredential. +nested Windows operations use a supplied PSCredential or the administrator +credential from the installed Jumpstart configuration. Passwords are not logged. Missing required Azure CLI extensions are installed without upgrading existing versions, including during WhatIf. Azure, Hyper-V and storage changes remain simulated during WhatIf. @@ -35,6 +36,23 @@ param( [string]$ManifestPath = 'C:\LocalBox\sovereign-localbox.json' ) +function Resolve-LocalBoxNodeCredential { + param([hashtable]$Configuration, [pscredential]$Credential) + if ($Credential) { return $Credential } + $domain = ([string]$Configuration.SDNDomainFQDN).Split('.')[0] + if ($domain -notmatch '^[a-zA-Z0-9][a-zA-Z0-9-]{0,14}$' -or + $Configuration.SDNAdminPassword -isnot [string] -or + [string]::IsNullOrWhiteSpace($Configuration.SDNAdminPassword)) { + throw 'The installed LocalBox configuration lacks a usable SDNDomainFQDN or SDNAdminPassword. Supply -NodeCredential for the nested administrator; do not print or share the configuration.' + } + $password = [Security.SecureString]::new() + foreach ($character in $Configuration.SDNAdminPassword.GetEnumerator()) { + $password.AppendChar($character) + } + $password.MakeReadOnly() + return [pscredential]::new("$domain\Administrator", $password) +} + function ConvertTo-LocalBoxIPv4Number { param([Parameter(Mandatory)][string]$Address) $parsed = [System.Net.IPAddress]::Parse($Address) @@ -234,6 +252,15 @@ function Sync-LocalBoxResource { } } +function Assert-LocalBoxImagePlacement { + param([array]$Resources, [string]$ImageId, [string]$StorageId) + if (@($Resources | Where-Object { $_.id -ieq $ImageId }).Count -eq 0) { return } + $image = Get-LocalBoxResource $ImageId + if ($image.properties.containerId -ine $StorageId) { + throw "Image '$ImageId' is on storage '$($image.properties.containerId)', but preparation requires '$StorageId' (UserStorage1). Have the facilitator review image dependencies and resolve the conflicting placement, then rerun with the documented image name. Existing images are never deleted or moved automatically." + } +} + function Get-LocalBoxNodeState { param([string]$Node, [pscredential]$Credential) $state = Invoke-Command -VMName $Node -Credential $Credential -ErrorAction Stop -ScriptBlock { @@ -399,7 +426,7 @@ function Invoke-LocalBoxPreparation { if (-not $Settings.AddressReservationsConfirmed) { throw 'Verify BOTH pools against DHCP leases/exclusions and static reservations, then pass -AddressReservationsConfirmed. The script does not alter DHCP/router configuration.' } - if (-not $Settings.NodeCredential) { $Settings.NodeCredential = Get-Credential -Message 'Windows administrator of the nested Azure Local nodes (not the Azure identity)' } + $Settings.NodeCredential = Resolve-LocalBoxNodeCredential -Configuration $config -Credential $Settings.NodeCredential $previousConfig = $env:AZURE_CONFIG_DIR $previousExtensions = $env:AZURE_EXTENSION_DIR $previousDynamicInstall = $env:AZURE_EXTENSION_USE_DYNAMIC_INSTALL @@ -449,17 +476,18 @@ function Invoke-LocalBoxPreparation { } $provider = Invoke-LocalBoxAz @('provider', 'show', '--namespace', 'Microsoft.EdgeMarketplace') if ($provider.registrationState -ne 'Registered') { throw 'Ask the subscription administrator to register Microsoft.EdgeMarketplace before importing the image.' } + $storage = Resolve-LocalBoxStoragePath -Resources $resources -Name UserStorage1 -CustomLocationId $custom.id + $storageId = $storage.id + $imageId = "$scope/providers/Microsoft.AzureStackHCI/marketplaceGalleryImages/$($Settings.ImageName)" + Assert-LocalBoxImagePlacement -Resources $resources -ImageId $imageId -StorageId $storageId Write-Host 'Preparing nested disks and UserStorage_1; secondary storage is preserved unless explicitly requested...' Initialize-LocalBoxStorage -Configuration $config -Credential $Settings.NodeCredential -DesiredSizeGB $Settings.StorageSizeGB ` -Subscription $Settings.SubscriptionId -ResourceGroup $Settings.ResourceGroupName -RemoveSecondary:$Settings.RemoveUserStorage2 $state = Get-LocalBoxNodeState $config.NodeHostConfig[0].Hostname $Settings.NodeCredential if (-not $WhatIfPreference -and ($state.Size -lt ($Settings.StorageSizeGB * 1GB) -or $state.Free -lt 100GB)) { throw 'UserStorage_1 is too small or has less than 100 GiB free.' } - $storage = Resolve-LocalBoxStoragePath -Resources $resources -Name UserStorage1 -CustomLocationId $custom.id - $storageId = $storage.id $location = $custom.location $timeout = $Settings.TimeoutMinutes * 60 $common = @('--subscription', $Settings.SubscriptionId, '--resource-group', $Settings.ResourceGroupName, '--location', $location, '--custom-location', $custom.id) - $imageId = "$scope/providers/Microsoft.AzureStackHCI/marketplaceGalleryImages/$($Settings.ImageName)" $imageExpected = @{ extendedLocation = @{ name = $custom.id }; location = $location properties = @{ containerId = $storageId; osType = 'Windows'; identifier = @{ 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 3ee396a08..4717c0d00 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 @@ -17,7 +17,7 @@ Both paths use [LocalBox preparation](localbox/readme.md) and the [Pester health | Participant Azure AKS, confidential VM, K3s, attestation and networking | Console content automation | Subscription owner using the lab template | | Shared LocalBox deployment | Console submits once per 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 dependency; obtain the object ID from the Console owner | Supply an existing security group with intended users as members | +| 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 | 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 2bdd5d73d..a195afb66 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 @@ -1,6 +1,164 @@ BeforeAll { . "$PSScriptRoot/../prepare-localbox.ps1" . "$PSScriptRoot/../test-sovereign-cloud.ps1" + . "$PSScriptRoot/../../labautomation/localbox-credentials.ps1" +} + +Describe 'Console lab group credentials' { + BeforeAll { + function Get-MhhDefaultLabGroup { [CmdletBinding()] param() } + } + BeforeEach { + Mock Get-MhhDefaultLabGroup { + @{ ObjectId = 'a0000000-0000-0000-0000-000000000001'; GroupName = 'lab-group'; DisplayName = 'Test event' } + } + } + It 'emits the three separate Console credential hashtables' { + $credentials = @(Get-LocalBoxConsoleGroupCredential) + $credentials.Count | Should -Be 3 + foreach ($credential in $credentials) { + $credential | Should -BeOfType [hashtable] + $credential.HackboxCredential | Should -BeOfType [hashtable] + } + $credentials[0].HackboxCredential.name | Should -Be 'Lab Group ObjectId' + $credentials[0].HackboxCredential.value | Should -Be 'a0000000-0000-0000-0000-000000000001' + $credentials[1].HackboxCredential.name | Should -Be 'Lab Group GroupName' + $credentials[1].HackboxCredential.value | Should -Be 'lab-group' + $credentials[2].HackboxCredential.name | Should -Be 'Lab Group DisplayName' + $credentials[2].HackboxCredential.value | Should -Be 'Test event' + Should -Invoke Get-MhhDefaultLabGroup -Times 1 -Exactly + } + It 'rejects incomplete or invalid group metadata before emitting anything' -TestCases @( + @{ Group = $null } + @{ Group = @{ ObjectId = 'not-a-guid'; GroupName = 'lab'; DisplayName = 'Event' } } + @{ Group = @{ ObjectId = [guid]::Empty; GroupName = 'lab'; DisplayName = 'Event' } } + @{ Group = @{ ObjectId = 'a0000000-0000-0000-0000-000000000001'; GroupName = ''; DisplayName = 'Event' } } + @{ Group = @{ ObjectId = 'a0000000-0000-0000-0000-000000000001'; GroupName = 'lab'; DisplayName = '' } } + ) { + param($Group) + Mock Get-MhhDefaultLabGroup { $Group } + $captured = [System.Collections.Generic.List[object]]::new() + { Get-LocalBoxConsoleGroupCredential | ForEach-Object { $captured.Add($_) } } | Should -Throw '*valid default lab group*' + $captured.Count | Should -Be 0 + } + It 'stops if the Console helper fails' { + Mock Get-MhhDefaultLabGroup { throw 'Console unavailable' } + { Get-LocalBoxConsoleGroupCredential } | Should -Throw '*Console unavailable*' + } +} + +Describe 'Console LocalBox credential isolation' { + BeforeAll { + function New-MhhStablePassword { [CmdletBinding()] param($Purpose, $Length) } + function Update-MhhToken { [CmdletBinding()] param() } + function Get-AzResourceGroupDeployment { [CmdletBinding()] param($ResourceGroupName, $Name) } + } + BeforeEach { + $deployment = @{ + ProvisioningState = 'Succeeded' + } + Mock Update-MhhToken {} + Mock Get-AzResourceGroupDeployment { $deployment } + Mock New-MhhStablePassword { 'mock-password-not-a-secret' } + Mock Start-Sleep {} + } + It 'emits no administrator credentials on success or reruns' { + $first = @(Wait-LocalBoxDeployment -ResourceGroupName 'shared' -DeploymentName 'localbox-test') + $second = @(Wait-LocalBoxDeployment -ResourceGroupName 'shared' -DeploymentName 'localbox-test') + $first.Count | Should -Be 0 + $second.Count | Should -Be 0 + Should -Invoke New-MhhStablePassword -Times 0 + Should -Invoke Get-AzResourceGroupDeployment -Times 2 -Exactly -ParameterFilter { $ResourceGroupName -eq 'shared' -and $Name -eq 'localbox-test' } + } + It 'waits for ARM success and refreshes authentication before each check' { + $deployment.ProvisioningState = 'Running' + Mock Start-Sleep { $deployment.ProvisioningState = 'Succeeded' } + @(Wait-LocalBoxDeployment -ResourceGroupName 'shared' -DeploymentName 'localbox-test').Count | Should -Be 0 + Should -Invoke Update-MhhToken -Times 2 -Exactly + Should -Invoke Start-Sleep -Times 1 -Exactly + Should -Invoke New-MhhStablePassword -Times 0 + } + It 'does not publish passwords from failed or canceled deployments' -TestCases @( + @{ State = 'Failed' } + @{ State = 'Canceled' } + ) { + param($State) + $deployment.ProvisioningState = $State + { Wait-LocalBoxDeployment -ResourceGroupName 'shared' -DeploymentName 'localbox-test' } | Should -Throw '*ended as*' + Should -Invoke New-MhhStablePassword -Times 0 + } + It 'times out without publishing a password' { + $deployment.ProvisioningState = 'Running' + $script:clock = [DateTime]'2026-01-01T00:00:00Z' + Mock Get-Date { + $script:clock = $script:clock.AddSeconds(2) + $script:clock + } + { Wait-LocalBoxDeployment -ResourceGroupName 'shared' -DeploymentName 'localbox-test' -TimeoutSeconds 1 } | Should -Throw '*Timed out*' + Should -Invoke New-MhhStablePassword -Times 0 + } + It 'propagates deployment lookup errors without publishing a password' { + Mock Get-AzResourceGroupDeployment { throw 'Deployment lookup failed' } + { Wait-LocalBoxDeployment -ResourceGroupName 'shared' -DeploymentName 'localbox-test' } | Should -Throw '*Deployment lookup failed*' + 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 | Should -Match '-UseConsoleCredentials' + $source | Should -Match 'Get-LocalBoxConsoleGroupCredential' + $source | Should -Match 'Wait-LocalBoxDeployment -ResourceGroupName \$localBoxResourceGroupName -DeploymentName \$localBoxDeployment.DeploymentName' + $source | Should -Not -Match 'Get-LocalBoxConsoleCredential|LocalBox Admin Password|LocalBox Client Username' + $helper = Get-Content "$PSScriptRoot/../../labautomation/localbox-credentials.ps1" -Raw + $helper | Should -Not -Match 'New-MhhStablePassword|LocalBox Admin Password|LocalBox Client Username' + $deployer = Get-Content "$PSScriptRoot/../../labautomation/deploy-localbox.ps1" -Raw + $deployer | Should -Match "New-MhhStablePassword -Purpose 'localbox-admin-v1' -Length 24" + } +} + +Describe 'Console LocalBox password selection' { + BeforeAll { + function New-MhhStablePassword { [CmdletBinding()] param($Purpose, $Length) } + $deployer = [System.Management.Automation.Language.Parser]::ParseFile( + "$PSScriptRoot/../../labautomation/deploy-localbox.ps1", [ref]$null, [ref]$null) + $selection = $deployer.Find({ + param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text -eq '$UseConsoleCredentials' -and + $node.Extent.Text -match 'New-MhhStablePassword' + }, $true) + $passwordSelection = [scriptblock]::Create($selection.Extent.Text) + } + BeforeEach { + $originalEnvironmentPassword = $env:LOCALBOX_ADMIN_PASSWORD + $env:LOCALBOX_ADMIN_PASSWORD = $null + $UseConsoleCredentials = $true + $WindowsAdminPassword = $null + Mock New-MhhStablePassword { 'mock-password-not-a-secret' } + } + AfterEach { $env:LOCALBOX_ADMIN_PASSWORD = $originalEnvironmentPassword } + It 'assigns the stable password to the secure template input without emitting it' { + $output = @(. $passwordSelection) + $output.Count | Should -Be 0 + $WindowsAdminPassword | Should -Be 'mock-password-not-a-secret' + Should -Invoke New-MhhStablePassword -Times 1 -Exactly -ParameterFilter { $Purpose -eq 'localbox-admin-v1' -and $Length -eq 24 } + } + It 'rejects competing explicit or environment passwords' -TestCases @( + @{ Source = 'Parameter' } + @{ Source = 'Environment' } + ) { + param($Source) + if ($Source -eq 'Parameter') { $WindowsAdminPassword = 'mock-existing-password' } + else { $env:LOCALBOX_ADMIN_PASSWORD = 'mock-existing-password' } + { . $passwordSelection } | Should -Throw '*cannot be combined*' + Should -Invoke New-MhhStablePassword -Times 0 + } + It 'preserves manually supplied passwords outside Console' { + $UseConsoleCredentials = $false + $WindowsAdminPassword = 'mock-manual-password' + . $passwordSelection + $WindowsAdminPassword | Should -Be 'mock-manual-password' + Should -Invoke New-MhhStablePassword -Times 0 + } } Describe 'Lab cost-control tags' { @@ -18,6 +176,41 @@ Describe 'Lab cost-control tags' { } } +Describe 'Nested LocalBox credential resolution' { + It 'constructs only a credential from the installed configuration without printing secrets' { + $configuration = @{ SDNDomainFQDN = 'jumpstart.local'; SDNAdminPassword = 'mock-config-password' } + $output = @(Resolve-LocalBoxNodeCredential -Configuration $configuration *>&1) + $output.Count | Should -Be 1 + $output[0] | Should -BeOfType [pscredential] + $output[0].UserName | Should -Be 'jumpstart\Administrator' + $output[0].GetNetworkCredential().Password | Should -Be 'mock-config-password' + } + It 'uses the configured domain rather than a hardcoded account' { + $credential = Resolve-LocalBoxNodeCredential -Configuration @{ SDNDomainFQDN = 'event.example.test'; SDNAdminPassword = 'mock-config-password' } + $credential.UserName | Should -Be 'event\Administrator' + } + It 'preserves an explicit credential even when configuration credentials are missing' { + $explicit = [pscredential]::new('custom\operator', [Security.SecureString]::new()) + $actual = Resolve-LocalBoxNodeCredential -Configuration @{} -Credential $explicit + [object]::ReferenceEquals($actual, $explicit) | Should -BeTrue + } + It 'fails without exposing configuration values when credentials are missing or invalid' -TestCases @( + @{ Configuration = @{} } + @{ Configuration = @{ SDNDomainFQDN = 'jumpstart.local'; SDNAdminPassword = '' } } + @{ Configuration = @{ SDNDomainFQDN = '.local'; SDNAdminPassword = 'mock-config-password' } } + @{ Configuration = @{ SDNDomainFQDN = 'invalid\domain'; SDNAdminPassword = 'mock-config-password' } } + ) { + param($Configuration) + { Resolve-LocalBoxNodeCredential -Configuration $Configuration } | Should -Throw '*Supply -NodeCredential*' + try { Resolve-LocalBoxNodeCredential -Configuration $Configuration } + catch { $_.ToString() | Should -Not -Match 'mock-config-password' } + } + It 'uses the resolver during preparation without an unconditional credential prompt' { + ${function:Invoke-LocalBoxPreparation}.ToString() | Should -Match 'Resolve-LocalBoxNodeCredential -Configuration \$config -Credential \$Settings.NodeCredential' + ${function:Invoke-LocalBoxPreparation}.ToString() | Should -Not -Match 'Get-Credential' + } +} + Describe 'LocalBox input validation' { It 'defaults to three AKS worker nodes' { . "$PSScriptRoot/../prepare-localbox.ps1" @@ -128,6 +321,59 @@ Describe 'Resource reconciliation' { } } +Describe 'LocalBox image storage conflicts' { + BeforeEach { + $scope = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI' + $imageName = '2025-datacenter-azure-edition-smalldisk-01' + $imageId = "$scope/marketplaceGalleryImages/$imageName" + $storageId = "$scope/storageContainers/UserStorage1-generated" + $resources = @(@{ id = $imageId }) + Mock Get-LocalBoxResource { @{ properties = @{ containerId = "$scope/storageContainers/UserStorage2-generated" } } } + Mock Invoke-LocalBoxAz { $resources } + } + It 'rejects an existing image on secondary storage even in WhatIf with actionable guidance' { + $WhatIfPreference = $true + { Assert-LocalBoxImagePlacement -Resources $resources -ImageId $imageId -StorageId $storageId } | Should -Throw '*review image dependencies*documented image name*never deleted or moved automatically*' + Should -Invoke Invoke-LocalBoxAz -Times 0 + } + It 'accepts matching storage IDs case-insensitively without writes' { + Mock Get-LocalBoxResource { @{ properties = @{ containerId = $storageId.ToUpperInvariant() } } } + { Assert-LocalBoxImagePlacement -Resources $resources -ImageId $imageId -StorageId $storageId } | Should -Not -Throw + Should -Invoke Invoke-LocalBoxAz -Times 0 + } + It 'rejects missing placement instead of assuming primary storage' { + Mock Get-LocalBoxResource { @{ properties = @{} } } + { Assert-LocalBoxImagePlacement -Resources $resources -ImageId $imageId -StorageId $storageId } | Should -Throw '*preparation requires*' + } + It 'permits the documented image when it does not yet exist without writes' { + Assert-LocalBoxImagePlacement -Resources @() -ImageId $imageId -StorageId $storageId + Should -Invoke Get-LocalBoxResource -Times 0 + Should -Invoke Invoke-LocalBoxAz -Times 0 + } + It 'checks placement before storage mutations' { + $source = ${function:Invoke-LocalBoxPreparation}.ToString() + $source.IndexOf('Assert-LocalBoxImagePlacement -Resources') | Should -BeGreaterOrEqual 0 + $source.IndexOf('Assert-LocalBoxImagePlacement -Resources') | Should -BeLessThan $source.IndexOf('Initialize-LocalBoxStorage -Configuration') + } + It 'creates the documented image on primary storage when it does not yet exist' { + Mock Invoke-LocalBoxAz { @() } + $expected = @{ properties = @{ containerId = $storageId } } + Mock Wait-LocalBoxResource { $expected } + Sync-LocalBoxResource -Id $imageId -Expected $expected -CreateArguments @('stack-hci-vm', 'image', 'create', '--name', $imageName, '--storage-path-id', $storageId) | Out-Null + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { + $Arguments[2] -eq 'create' -and $Arguments -contains $imageName -and $Arguments -contains $storageId + } + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'update' } + Should -Invoke Wait-LocalBoxResource -Times 1 -Exactly -ParameterFilter { $Id -eq $imageId } + } + It 'does not import the documented image during WhatIf' { + Mock Invoke-LocalBoxAz { @() } + Sync-LocalBoxResource -Id $imageId -Expected @{ properties = @{ containerId = $storageId } } ` + -CreateArguments @('stack-hci-vm', 'image', 'create') -WhatIf + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'create' } + } +} + Describe 'CLI process cleanup' { BeforeEach { Mock Get-LocalBoxAzInvocation { @{ Executable = 'az'; Prefix = @() } } 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 a0b2a7a6a..3e09e8b92 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 @@ -28,8 +28,11 @@ For a checkout, run the same command without `-DownloadTests`. ## Full LocalBox check +Keep a reviewed [prepare-localbox.ps1](../prepare-localbox.ps1) from the same ref beside the runner. The following builds the nested credential locally without printing the installed configuration; use `Get-Credential` instead if that account has been rotated. The health runner still requires the explicit `-NodeCredential` argument. + ```powershell -$nodeCredential = Get-Credential -Message 'Nested Azure Local Windows administrator' +. ./prepare-localbox.ps1 +$nodeCredential = Resolve-LocalBoxNodeCredential -Configuration (Import-PowerShellDataFile -LiteralPath $env:LocalBoxConfigFile) ./test-sovereign-cloud.ps1 -Scope LocalBox -Mode Full ` -LocalBoxManifestPath 'C:\LocalBox\sovereign-localbox.json' ` -LocalBoxKubeconfig 'C:\LocalBox\aks-local.kubeconfig' ` @@ -40,7 +43,7 @@ Checks cover the Client and bootstrap, cluster/Arc bridge/custom location/AKS ex ## Rerun from a local copy -In elevated PowerShell 7 on LocalBox-Client, change to your local resources directory containing the runner, preparation script and `tests` folder. Use the manifest written by preparation (`-ManifestPath`, default `C:\LocalBox\sovereign-localbox.json`) and an existing authenticated kubeconfig. Keep all scripts from the same reviewed revision; do not overwrite unpublished local fixes with an older download. +In elevated PowerShell 7 on LocalBox-Client, change to your local resources directory containing the runner, preparation script and `tests` folder. The offline unit suite also requires the sibling `labautomation` folder, including `localbox-credentials.ps1`, from the same checkout; it mocks the Console helpers and makes no live deployment calls. Use the manifest written by preparation (`-ManifestPath`, default `C:\LocalBox\sovereign-localbox.json`) and an existing authenticated kubeconfig. Keep all scripts from the same reviewed revision; do not overwrite unpublished local fixes with an older download. The commands below retain your existing Azure CLI authentication. Confirm that it has access to the selected lab resources as described in [Tooling and authentication](#tooling-and-authentication). @@ -51,7 +54,8 @@ $outputDirectory = Join-Path (Get-Location) 'health-results' Import-Module Pester -RequiredVersion 5.7.1 Invoke-Pester .\tests\prepare-localbox.tests.ps1 -Output Detailed -$nodeCredential = Get-Credential -Message 'Administrator of the nested Azure Local nodes' +. .\prepare-localbox.ps1 +$nodeCredential = Resolve-LocalBoxNodeCredential -Configuration (Import-PowerShellDataFile -LiteralPath $env:LocalBoxConfigFile) .\test-sovereign-cloud.ps1 -Scope LocalBox -Mode Full ` -LocalBoxManifestPath $manifestPath ` -LocalBoxKubeconfig $kubeconfigPath ` From 87e71f9d9ffabc19417498033775e1a73f57037c Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Sun, 20 Sep 2026 19:36:27 +0000 Subject: [PATCH 5/5] Remove Sovereign Cloud navigation links incompatible with Console --- .../challenges/challenge-01.md | 14 -------------- .../challenges/challenge-02.md | 14 -------------- .../challenges/challenge-03.md | 14 -------------- .../challenges/challenge-04.md | 14 -------------- .../challenges/challenge-05.md | 14 -------------- .../challenges/challenge-06.md | 14 -------------- .../challenges/challenge-07.md | 16 +--------------- .../01_Sovereign_Cloud/challenges/finish.md | 2 -- .../walkthrough/challenge-01/solution-01.md | 2 -- .../walkthrough/challenge-02/solution-02.md | 4 ---- .../walkthrough/challenge-03/solution-03.md | 4 ---- .../walkthrough/challenge-04/solution-04.md | 2 -- .../walkthrough/challenge-05/solution-05.md | 2 -- .../walkthrough/challenge-06/solution-06.md | 4 ---- .../walkthrough/challenge-07/solution-07.md | 2 -- 15 files changed, 1 insertion(+), 121 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-01.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-01.md index 62dac6f3f..4451b9551 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-01.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-01.md @@ -1,7 +1,5 @@ # Challenge 1 - Enforce Sovereign Controls with Azure Policy and RBAC -**[Home](../Readme.md)** - [Next Challenge](challenge-02.md) - - All policy assignments in this challenge should be scoped to your own resource group (e.g. "LabUser-01") - All resources created in Microsoft Entra should be prefixed with your prefix (e.g. "LabUser01") @@ -37,15 +35,3 @@ The goal of this exercise is to establish foundational sovereign cloud governanc - [Azure RBAC overview](https://learn.microsoft.com/azure/role-based-access-control/overview) - [Create custom roles for Azure RBAC](https://learn.microsoft.com/azure/role-based-access-control/custom-roles) - [Remediate non-compliant resources](https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources) - -## Solution - -> [!TIP] -> We encourage you to try solving the challenge on your own before looking at the solution. This will help you learn and understand the concepts better. - -
-Click here to view the solution - -[Solution for Challenge 1](../walkthrough/challenge-01/solution-01.md) - -
diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-02.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-02.md index 6a2fb50fc..d347e9a19 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-02.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-02.md @@ -1,7 +1,5 @@ # Challenge 2 - Encryption at Rest with Customer-Managed Keys (CMKs) in Azure Key Vault -[Previous Challenge](challenge-01.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-03.md) - ## Goal Understand Customer-Managed Keys in Azure Key Vault. Configure an Azure Storage account to use a customer-managed key stored in Azure Key Vault (or Azure Managed HSM) for encryption at rest. Validate the configuration and understand operational considerations for sovereign scenarios. @@ -27,15 +25,3 @@ Understand Customer-Managed Keys in Azure Key Vault. Configure an Azure Storage * [What is Azure Key Vault Managed HSM?](https://learn.microsoft.com/azure/key-vault/managed-hsm/overview) * [Azure Key & Certificate Management](https://learn.microsoft.com/azure/key-vault/general/overview) * [Services that support CMKs with Key Vault & Managed HSM](https://learn.microsoft.com/azure/security/fundamentals/encryption-customer-managed-keys-support) - -## Solution - -> [!TIP] -> We encourage you to try solving the challenge on your own before looking at the solution. This will help you learn and understand the concepts better. - -
-Click here to view the solution - -[Solution for Challenge 2](../walkthrough/challenge-02/solution-02.md) - -
diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-03.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-03.md index 33a367c7a..7ca7a6917 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-03.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-03.md @@ -1,7 +1,5 @@ # Challenge 3 - Encryption in transit: enforcing TLS -[Previous Challenge](challenge-02.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-04.md) - ## Goal Understand encryption in transit considerations for sovereign scenarios. Verify that Azure Storage accounts require secure transfer (HTTPS only), confirm the TLS 1.2+ baseline, apply Azure Policy for governance, and monitor client protocol usage through Log Analytics. @@ -27,15 +25,3 @@ Understand encryption in transit considerations for sovereign scenarios. Verify * [Enforce a minimum required TLS version for Storage](https://learn.microsoft.com/azure/storage/common/transport-layer-security-configure-minimum-version) * [Azure Resource Manager TLS support](https://learn.microsoft.com/azure/azure-resource-manager/management/tls-support) * [Policy: Storage accounts should have the specified minimum TLS version](https://learn.microsoft.com/azure/storage/common/transport-layer-security-configure-minimum-version?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&tabs=portal#detect-the-tls-version-used-by-client-applications) - -## Solution - -> [!TIP] -> We encourage you to try solving the challenge on your own before looking at the solution. This will help you learn and understand the concepts better. - -
-Click here to view the solution - -[Solution for Challenge 3](../walkthrough/challenge-03/solution-03.md) - -
diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md index 05fe8dc43..5401ffc8c 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-04.md @@ -1,7 +1,5 @@ # Challenge 4 - Encryption in use with Azure Confidential Compute – VM -[Previous Challenge](challenge-03.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-05.md) - ## Goal Validate guest attestation on a pre-provisioned Azure Confidential VM to confirm that business logic only executes in a trusted, hardware-backed confidential computing environment. You will inspect the VM's security configuration, connect through Azure Bastion using username/password credentials, build and run a sample attestation application, and verify the cryptographic JWT proof of VM integrity using a dedicated Microsoft Azure Attestation (MAA) provider. @@ -24,15 +22,3 @@ Validate guest attestation on a pre-provisioned Azure Confidential VM to confirm * [Azure Confidential Computing Overview](https://learn.microsoft.com/azure/confidential-computing/overview) * [Microsoft Azure Attestation](https://learn.microsoft.com/azure/attestation/overview) * [About Azure confidential VMs](https://learn.microsoft.com/azure/confidential-computing/confidential-vm-overview) - -## Solution - -> [!TIP] -> We encourage you to try solving the challenge on your own before looking at the solution. This will help you learn and understand the concepts better. - -
-Click here to view the solution - -[Solution for Challenge 4](../walkthrough/challenge-04/solution-04.md) - -
diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md index 4759263c0..601004fd9 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-05.md @@ -1,7 +1,5 @@ # Challenge 5 - Encryption in use with Confidential VMs/Node Pools in Azure Kubernetes Service (AKS) -[Previous Challenge](challenge-04.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-06.md) - ## Goal Validate guest attestation on a pre-provisioned Azure Confidential VM node pool in AKS to ensure business logic only executes in trusted, compliant confidential computing environments. You'll inspect the AKS cluster and Confidential VM node pool that were deployed for you, then deploy a sample application that implements secure attestation flows. The application leverages Microsoft Azure Attestation (MAA) to validate VM integrity before executing protected business logic, demonstrating "encryption in use" capabilities. @@ -24,15 +22,3 @@ Validate guest attestation on a pre-provisioned Azure Confidential VM node pool * [Azure Confidential Computing Overview](https://learn.microsoft.com/azure/confidential-computing/overview) * [Microsoft Azure Attestation](https://learn.microsoft.com/azure/attestation/overview) * [Confidential containers on Azure](https://learn.microsoft.com/azure/confidential-computing/confidential-containers) - -## Solution - -> [!TIP] -> We encourage you to try solving the challenge on your own before looking at the solution. This will help you learn and understand the concepts better. - -
-Click here to view the solution - -[Solution for Challenge 5](../walkthrough/challenge-05/solution-05.md) - -
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 c9aaea774..b6eb5d46d 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 @@ -1,7 +1,5 @@ # Challenge 6 - Operating a Sovereign Hybrid Cloud with Azure Arc & Azure Local -[Previous Challenge](challenge-05.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-07.md) - ## Goal The goal of this challenge is to operate a sovereign hybrid cloud environment by combining Microsoft Sovereign Public Cloud and Sovereign Private Cloud components. You will work with Azure Local, simulated via Azure Arc Jumpstart LocalBox, and provision your own VM. You will use Azure Arc to manage that VM through Azure, review its security posture with Microsoft Defender for Cloud, and assess its OS updates with Azure Update Manager. @@ -34,15 +32,3 @@ Your organization must run workloads in a sovereign cloud while still leveraging * [Microsoft Defender for Cloud with Arc-enabled servers](https://learn.microsoft.com/azure/defender-for-cloud/quickstart-onboard-machines) * [Azure Update Manager overview](https://learn.microsoft.com/azure/update-manager/overview) * [Azure Arc Jumpstart - LocalBox](https://jumpstart.azure.com/azure_jumpstart_localbox) - -## Solution - -> [!TIP] -> We encourage you to try solving the challenge on your own before looking at the solution. This will help you learn and understand the concepts better. - -
-Click here to view the solution - -[Solution for Challenge 6](../walkthrough/challenge-06/solution-06.md) - -
diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md index 59708cad4..a832a4997 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-07.md @@ -1,7 +1,5 @@ # Challenge 7 - Adaptive Apps Across Sovereign Environments -[Previous Challenge](challenge-06.md) - **[Home](../Readme.md)** - [Finish](finish.md) - ## Goal Deploy one unchanged application model to two sovereign execution environments: Azure Kubernetes Service (AKS) and a private, self-managed K3s cluster representing an on-premises or edge location. Use independent [Radius](https://radapp.io/) control planes so either site can continue operating when the other site or its network connection is unavailable. @@ -52,16 +50,4 @@ The lab platform has already provisioned: * [Microsoft Entra Workload ID on AKS](https://learn.microsoft.com/azure/aks/workload-identity-overview) * [Azure Bastion native client connections](https://learn.microsoft.com/azure/bastion/connect-vm-native-client-windows) * [Azure Arc-enabled Kubernetes overview](https://learn.microsoft.com/azure/azure-arc/kubernetes/overview) -* [Full Adaptive Apps MicroHack source](https://github.com/djong1/MicroHack/tree/djong1-adaptive-apps-microhack/03-Azure/01-01-App%20Innovation/04-adaptive-apps) - -## Solution - -> [!TIP] -> Attempt the challenge before opening the worked solution. Use the success criteria to decide when you are finished. - -
-Click here to view the solution - -[Solution for Challenge 7](../walkthrough/challenge-07/solution-07.md) - -
\ No newline at end of file +* [Full Adaptive Apps MicroHack source](https://github.com/djong1/MicroHack/tree/djong1-adaptive-apps-microhack/03-Azure/01-01-App%20Innovation/04-adaptive-apps) \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/finish.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/finish.md index cf2782c07..2a78f9dbc 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/finish.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/finish.md @@ -1,7 +1,5 @@ # Finish -[Previous Challenge](challenge-06.md) - **[Home](../Readme.md)** - Congratulations! You finished the MicroHack Microsoft Sovereign Cloud. We hope you had the chance to learn about how to implement sovereign cloud controls using Azure native platform capabilities — from policy and RBAC, through encryption at rest, in transit, and in use, to operating a hybrid sovereign environment with Azure Arc and Azure Local. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-01/solution-01.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-01/solution-01.md index 9f7f5162d..41db0e2ad 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-01/solution-01.md @@ -1,7 +1,5 @@ # Walkthrough Challenge 1 - Enforce Sovereign Controls with Azure Policy and RBAC -**[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-02/solution-02.md) - **Estimated Duration:** 45 minutes > 💡 **Objective:** Learn how to enforce sovereign cloud governance controls using Azure native platform capabilities. You will restrict resource deployments to sovereign regions, enforce compliance through tagging and network policies, implement least-privilege access with RBAC, and remediate non-compliant resources. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-02/solution-02.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-02/solution-02.md index 0a160dbae..b4243ce26 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-02/solution-02.md @@ -1,7 +1,5 @@ # Walkthrough Challenge 2 - Encryption at Rest with Customer-Managed Keys (CMKs) in Azure Key Vault -[Previous Challenge Solution](../challenge-01/solution-01.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-03/solution-03.md) - **Estimated Duration:** 30 minutes > 💡 **Objective:** Understand Customer-Managed Keys in Azure Key Vault. Configure an Azure Storage account to use a customer-managed key stored in Azure Key Vault (or Azure Managed HSM) for encryption at rest. Validate the configuration and understand operational considerations for sovereign scenarios. @@ -268,5 +266,3 @@ If you navigate to the key inside your Key Vault, you should now see a new versi --- You successfully completed challenge 2! 🚀🚀🚀 - - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-03/solution-03.md) diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-03/solution-03.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-03/solution-03.md index d6b1a36af..5b9638068 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-03/solution-03.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-03/solution-03.md @@ -1,7 +1,5 @@ # Walkthrough Challenge 3 - Encryption in transit: enforcing TLS -[Previous Challenge Solution](../challenge-02/solution-02.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-04/solution-04.md) - **Estimated Duration:** 30 minutes > 💡 **Objective:** Understand encryption in transit considerations for sovereign scenarios. Verify that Azure Storage accounts require secure transfer (HTTPS only), confirm the TLS 1.2+ baseline, apply Azure Policy for governance, and monitor client protocol usage through Log Analytics. @@ -281,5 +279,3 @@ Confirm that requests use TLS 1.2 or TLS 1.3. --- You successfully completed challenge 3! 🚀🚀🚀 - - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-04/solution-04.md) diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md index 8a7819c51..d81eb0233 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-04/solution-04.md @@ -1,7 +1,5 @@ # Walkthrough Challenge 4 - Encryption in use with Azure Confidential Compute – VM -[Previous Challenge Solution](../challenge-03/solution-03.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-05/solution-05.md) - **Estimated Duration:** 45-60 minutes > 💡 **Objective:** Validate guest attestation on a pre-provisioned Azure Confidential VM to confirm that workloads execute only in trusted, hardware-backed confidential computing environments. You will inspect the VM's security configuration, connect through Azure Bastion, build and run attestation client applications, and verify cryptographic proof of VM integrity using your dedicated Microsoft Azure Attestation (MAA) provider. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md index 980bf6f58..11f769f4f 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-05/solution-05.md @@ -1,7 +1,5 @@ # Walkthrough Challenge 5 - Encryption in use with Confidential VMs/Node Pools in Azure Kubernetes Service (AKS) -[Previous Challenge Solution](../challenge-04/solution-04.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-06/solution-06.md) - **Estimated Duration:** 45-60 minutes > 💡 **Objective:** Learn how to validate guest attestation on Azure Confidential VMs in AKS to ensure business logic only executes in trusted, hardware-backed confidential computing environments. Using the AKS cluster and Confidential VM node pool that have already been provisioned for this lab, you will inspect the node pool configuration, deploy attestation workloads, and verify cryptographic proof of node integrity before processing sensitive operations. 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 813333fef..a8f1d2bea 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 @@ -1,7 +1,5 @@ # Walkthrough Challenge 6 - Operating a Sovereign Hybrid Cloud with Azure Arc & Azure Local -[Previous Challenge Solution](../challenge-05/solution-05.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-07/solution-07.md) - **Estimated Duration:** 60-90 minutes > 💡 **Objective:** Operate a sovereign hybrid cloud environment combining Azure Local and Azure Arc. You'll learn how to apply consistent governance, security, and management across on-premises sovereign infrastructure and Azure. @@ -322,5 +320,3 @@ Before completing this challenge, verify: --- You successfully completed Challenge 6! 🚀🚀🚀 - -**[Home](../../Readme.md)** - [Previous Challenge Solution](../challenge-05/solution-05.md) diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md index f1771610b..0dd9e8643 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-07/solution-07.md @@ -1,7 +1,5 @@ # Walkthrough Challenge 7 - Adaptive Apps Across Sovereign Environments -[Previous Challenge Solution](../challenge-06/solution-06.md) - **[Home](../../Readme.md)** - Duration: 60 minutes ## Prerequisites