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 d7b924615..eaced9551 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 @@ -19,6 +19,16 @@ Before starting, return your Challenge 1 exercise policies to **DoNotEnforce**. * Verify Microsoft Defender for Cloud coverage and review security recommendations for the VM you provisioned * Use Azure Update Manager to assess OS updates on the VM you provisioned +MDE onboarding can take up to an hour after Arc connects when Endpoint integration +is already established. Continue with Update Manager and the AKS tasks while waiting. +Recommendations may appear later; you do not need a nonzero count to complete the +challenge. Record pending assessment separately from confirmed protection. + +If your facilitator provides `sov-ref-vm`, inspect its existing Defender and Update +Manager results while your own VM onboards. **Do not modify, run assessments on, +patch, restart or delete this shared VM.** Still complete the deployment and +assessment steps on your own VM, and record which VM each observation describes. + ### Review the AKS load balancer and deploy an app * Review the preinstalled **MetalLB load balancer**: check that it is healthy and understand how it assigns IP addresses to applications @@ -34,7 +44,7 @@ In a real deployment, clients would reach the app through its MetalLB IP address * You can navigate and understand the LocalBox hybrid environment in the Azure Portal * You have deployed your own VM on Azure Local via the Azure Portal and verified that guest management is Enabled (Connected) -* You have verified Defender for Servers coverage for your VM and reviewed its available recommendations, or identified that its assessment is still pending +* You have verified Defender for Servers plan coverage, recorded MDE extension/onboarding status separately, and reviewed available recommendations or recorded that assessment is pending/not yet confirmed * You have completed an Azure Update Manager assessment for your VM and reviewed the results, including when no updates are pending * You can explain MetalLB's role and have checked that the preinstalled load balancer is healthy * Your sample application runs in your team's namespace and has a load-balancer IP address 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 f4e7d84d5..8b10777e4 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 @@ -47,6 +47,7 @@ The shared hook submits `localbox-*` in `rg-localbox-shared`, then waits up to s 2. If the Client password is unknown, an authorized event lead/coach uses the Azure VM's **Help > Reset password** to set a known password for the `arcdemo` host account, then connects to `LocalBox-Client` through Bastion. Do not expose this password through participant credentials or job logs. A host password reset does not change the nested-node Windows credentials. See [LocalBox credentials](#localbox-credentials). 3. Copy **Lab Group ObjectId** from the Console's **Credentials** tab for the preparation prompt or `-AksAdminGroupObjectId`. Confirm that intended Azure lab identities/coaches are members; see the group dependency below. 4. Follow [LocalBox preparation](../localbox/readme.md), once per shared subscription, in elevated PowerShell 7. It creates AKS and then installs/verifies the shared MetalLB extension and reserved ARP pool. Azure authentication uses the Client VM's managed identity; preparation constructs the nested Windows credential locally from the installed configuration without printing it. An explicit `-NodeCredential` overrides that value after a nested-account rotation. + Preparation includes `sov-ref-vm` by default; run it **the day before the event** for inspection while participant VMs onboard, or opt out with `-IncludeReferenceVm:$false`. Follow the [reference readiness checks](../localbox/readme.md#reference-readiness-and-participant-use); Console plan configuration and successful provisioning do not establish completed VM assessments. Participants must not modify this shared VM; no RBAC restrictions are added. 5. Run [Pester health checks](../tests/readme.md) for LocalBox and every selected participant lab. A control-plane-only pass does not establish full readiness. 6. Console shared setup enables and verifies **Defender for Servers Plan 2** and **Endpoint protection** on every event subscription. Complete the [Defender readiness checklist](../localbox/manual-preparation.md#defender-for-servers-readiness-organizer) and [participant VM readiness exercise](../localbox/manual-preparation.md#step-6-test-the-environment), including guest management, actual Defender onboarding and Update Manager. Verified subscription settings do not prove that a VM has completed onboarding. 7. Verify an intended participant can use Entra-authenticated `az connectedk8s proxy` and a private application port-forward from their Codespace. Participants inspect MetalLB, not install it, and never need LocalBox-Client access. This validates the lab access path, not routing to the MetalLB service VIP. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md index fabfdb0a2..541a8309d 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md @@ -54,7 +54,7 @@ You should also see updated values in the Azure Portal: ![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 +- **IP Pools**: 192.168.200.10 - 192.168.200.199 for the standard greenfield LocalBox configuration. For customized networks, verify 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 @@ -116,6 +116,8 @@ do not repeatedly toggle integration to force deployment. ### Step 6: Test the Environment +The preparation script includes a [reference VM](readme.md#reference-vm) by default; verify its assessment results before the event, or opt out with `-IncludeReferenceVm:$false`. This does not replace the participant-identity test below. + 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 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 342e3219a..c74989a08 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 @@ -11,8 +11,9 @@ Run [prepare-localbox.ps1](../prepare-localbox.ps1) **inside LocalBox-Client**, - 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. - `Microsoft.KubernetesRuntime` registered, and its tenant-specific service-principal **object ID** available for MetalLB. Updated Console setup stores this nonsecret ID in the shared resource group's `microhack-k8s-runtime-object-id` tag. Manual organizers supply `-KubernetesRuntimeObjectId`; see [MetalLB preparation](#metallb-preparation) below. The Client managed identity needs write access to cluster extensions and KubernetesRuntime load balancers at the AKS connected-cluster scope; do not grant participants those permissions for installation. - 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 node/VM pools **and the AKS service VIP range**, sufficient backing `V:` capacity and event budget. Dynamic disks do not add physical backing capacity. +- `Microsoft.EdgeMarketplace` registered by the subscription owner. The Client managed identity needs read access to the cluster's Edge Marketplace offers for image-version discovery. 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. +- The standard greenfield LocalBox network configuration. For customized networks, review DHCP exclusions/static reservations for both node/VM pools **and the AKS service VIP range** before preparation. +- 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. @@ -25,15 +26,19 @@ 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' -./prepare-localbox.ps1 -AddressReservationsConfirmed -WhatIf -./prepare-localbox.ps1 -AddressReservationsConfirmed +./prepare-localbox.ps1 -WhatIf +./prepare-localbox.ps1 ``` 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. +The reference VM is **enabled by default**; pass `-IncludeReferenceVm:$false` to skip its preparation. Operation confirmation prompts are off under PowerShell's default confirmation preference, including storage changes and recovery of a matching Failed image. Use `-Confirm` to request confirmations, or `-Confirm:$false` if your session has a stricter preference. `-WhatIf` and all configuration/health safety checks remain active. Group-ID and first-creation credential prompts are separate; supply `-AksAdminGroupObjectId` and `-ReferenceVmCredential` to avoid those inputs. + +Preparation runs in this order: **storage -> logical networks -> AKS, proxy access and MetalLB -> VM image -> reference VM (unless disabled)**. A late Marketplace image failure therefore leaves the networks and AKS resources in place for the next run to reuse; it does not roll them back. The final manifest is written only after the requested preparation finishes. + +If the Console-owned group is not available yet, `-SkipAks` prepares storage, both networks, the VM image and the reference VM (unless disabled). 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` installs missing CLI extensions as a deliberate local-tooling exception, then performs authenticated reads and validation. It does not provision Azure resources, assign roles or change Hyper-V or storage. The RBAC step only reports the planned group/cluster assignment, without querying a cluster that may not exist yet; it does not validate role-assignment permissions. It can create and remove a temporary local CLI profile. It does not establish that later resource creation will succeed. @@ -43,15 +48,30 @@ If the Console-owned group is not available yet, `-SkipAks` explicitly prepares | --- | --- | | 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 | `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 | +| VM network | `localbox-vm-lnet-vlan200`, VLAN 200, `192.168.200.0/24`, pool `.10-.199` for the standard LocalBox configuration | | 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 MetalLB service VIPs, separate from nodes `.101-.199` | | AKS cluster | `localbox-aks`, one control-plane node and three Linux workers; `Standard_A4_v2` defaults, subject to installed-version capacity/CLI validation | | AKS proxy access | Azure Arc Enabled Kubernetes Cluster User Role for `AksAdminGroupObjectId`, scoped to the connected-cluster resource; matching direct or inherited grants are reused | | MetalLB | `arcnetworking` Arc extension (`microsoft.arcnetworking`), ARP pool `aks-pool`, addresses from `AKSVIPStartIP` through `AKSVIPEndIP` (standard config: `10.10.0.10-10.10.0.100`) | +| Reference VM | `sov-ref-vm`, Windows Server 2025, 2 vCPU / 4 GiB RAM, shared VM network and `UserStorage1`; enabled by default, opt out with `-IncludeReferenceVm:$false` | | 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. +Topology defaults come from the installed Jumpstart config, except the VM pool bounds and configurable resource names/sizes. Preparation assumes the standard greenfield LocalBox topology and requires no manual address-reservation acknowledgement. It still validates subnet boundaries, gateways, configured infrastructure addresses, and AKS node/control-plane/VIP separation. These checks do not inspect live DHCP leases/exclusions or discover additional static consumers. Review those separately for customized networks; the script does not change routers or DHCP configuration. + +## Reruns and failed image imports + +Rerun with the same settings after resolving a failure. Storage is checked against its **actual state**, not a completion flag: existing disks are not recreated or reattached, and a sufficiently sized virtual disk is not resized. The partition-extension step (including its confirmation and supported-size query) is skipped once the partition fills the disk, allowing for its alignment/GPT tail. If a previous run grew the virtual disk but stopped before extending the partition, the next run completes only that unfinished step. + +Read-only health checks still run, and preparation still waits for active storage jobs before dependent operations. A rerun must not bypass unhealthy or busy storage simply because an earlier run completed. + +If the configured Marketplace image exists in **Failed** state, preparation verifies its region, custom location, storage path and image settings, then **deletes that exact image and retries its import**. Use `-Confirm` to request confirmation first. It verifies that deletion has finished before creating the replacement. A matching deletion already in progress is awaited. `-WhatIf` previews recovery without deleting or importing anything. Healthy/in-progress images are reused or awaited; conflicting or incomplete image metadata requires organizer review and is never deleted automatically. + +Before a new import or failed-image deletion, the default `-ImageVersion latest` is resolved to the newest version **advertised by this Azure Local cluster's Edge Marketplace catalog**, and that exact version is passed to the import command. An explicit `-ImageVersion` must also be listed there. Catalog lookup failures stop preparation without deleting the failed image or guessing a fallback version. Existing healthy/in-progress images are reused or awaited without a catalog lookup or automatic upgrade. + +This avoids a mismatch observed on 21 October 2026: Azure Compute exposed `26100.33650.261013`, but the cluster's Edge Marketplace catalog only advertised versions through `26100.33438.260905` for the same Windows Server 2025 smalldisk SKU. Passing `latest` directly had resolved to the newer, unavailable Edge version and failed with `GenerateTokenFromEdgeMarketplaceServiceFailed` / `NotFound`. The script discovers the available version rather than hard-coding either example. + +There is **one import attempt per run**, not an endless retry loop. If the new attempt also fails, its original CLI error is reported and the failed resource remains available for investigation. The SAS-token error does not by itself prove a transient outage; check catalog availability, provider prerequisites and service status before another retry. See [Microsoft's image-management guidance](https://learn.microsoft.com/azure/azure-local/manage/virtual-machine-manage-image#delete-vm-image). ## MetalLB preparation @@ -76,7 +96,7 @@ grant Graph permissions to the Client identity. The VIP reservation is checked against the AKS subnet, gateway, control-plane address and node pool. Do not use `10.10.0.150-10.10.0.160` from older screenshots: -it overlaps the node pool. The address-reservation confirmation includes DHCP and +it overlaps the node pool. For customized networks, also review DHCP exclusions and other static consumers of the service VIPs. Matching extensions and pools are reused. Conflicting identities, address ranges, @@ -122,9 +142,43 @@ Use `-ImageVersion` to pin a Marketplace version, `-KubernetesVersion` for a sup 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. +## Reference VM + +Preparation includes one shared Windows VM for Challenge 6 by default, preferably prepared **the day before the event**. Opt out with `-IncludeReferenceVm:$false`. It uses the prepared image, VM network and storage. The fixed VM/computer name `sov-ref-vm` is 10 characters, below Windows' **15-character** limit. Allow for its additional memory, disk growth, address consumption and Defender costs. + +On an **existing LocalBox**, copy the updated [prepare-localbox.ps1](../prepare-localbox.ps1) to the Client (or download it from your published branch/commit). In elevated PowerShell 7, rerun with the same settings used for the original preparation: + +```powershell +./prepare-localbox.ps1 -WhatIf +./prepare-localbox.ps1 +``` + +Supply the existing AKS group ID when prompted, or pass `-AksAdminGroupObjectId`. Preserve any original nondefault image/network/AKS settings. This is an idempotent preparation rerun, **not** a new LocalBox deployment. It still validates/reconciles the normal shared resources; do not use `-SkipAks` just to bypass those checks. + +Creation prompts for a **separate local administrator credential**, not the nested domain administrator. Retain it securely for organizer maintenance; alternatively supply `-ReferenceVmCredential (Get-Credential -UserName 'localadmin')`. The password is passed through a restricted temporary ARM parameter file, never through CLI arguments or the manifest, and the file is removed after submission. Do not share the credential with participants. + +The script requests guest management and **periodic update assessment** (`AutomaticByPlatform`); it does not install patches, toggle Defender or force MDE installation. Defender for Servers P2 and Endpoint protection must already be enabled, and the VM needs outbound access to Arc, Defender and its configured update source. Resources carry `SecurityControl=Ignore`, `CostControl=Ignore` and `MicroHackPurpose=LocalBoxReferenceVM`. + +Matching resources are reused without password resets, resizing or redeployment. Conflicting/partial resources cause an explicit failure for organizer inspection; they are never deleted or overwritten. Reruns with `-IncludeReferenceVm:$false` neither manage nor delete the VM, and preserve its existing manifest entry (including the original observation timestamp). + +### Reference readiness and participant use + +The manifest's `ReferenceVm` entry records nonsecret IDs and an observed power/Arc/MDE status. **Provisioning is not proof of completed onboarding or assessment.** Pending, disconnected or failed states must be resolved before advertising the VM as ready. Health checks reread live status rather than trusting the saved snapshot. + +Before the workshop, verify: + +1. `sov-ref-vm` is running with **Guest management: Enabled (Connected)**. +2. `MDE.Windows` is **Succeeded**; verify actual device reporting through the Microsoft Defender portal with an authorized organizer account. +3. Defender for Cloud has assessment evidence for this VM. Review available recommendations or a completed assessment with no findings; an empty list alone is not sufficient. +4. Azure Update Manager shows a **successful assessment and timestamp**. Periodic assessment is asynchronous; an organizer can run **Check for updates** on this VM if no result is available yet. Do not install patches just to prepare the demonstration. + +The script intentionally leaves Defender assessment and update-result readiness as `NotVerified`; these portal checks remain required even after automated health tests pass. See the [Defender readiness checklist](manual-preparation.md#defender-for-servers-readiness-organizer). + +Participants still create and assess **their own VM**. While waiting, they may inspect this reference VM's existing security and update results. **Do not modify, assess, patch, restart, remediate or delete the shared reference VM.** This is an instruction, **not an RBAC-enforced read-only boundary**: existing shared resource-group VM Contributor grants remain unchanged. Verify participant visibility before the event; do not grant access to LocalBox-Client or publish the reference administrator credential. + ## 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. +On a new, empty environment, pass `-RemoveUserStorage2` to request removal of the unused second Azure storage path, CSV and virtual disk. This remains opt-in; use `-WhatIf` to preview or `-Confirm` to request 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. Confirmation suppression does not 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. 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 15b25cd35..38ce54429 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 @@ -14,6 +14,14 @@ Kubernetes Cluster User Role for proxy access, reusing applicable existing grant It also installs the MetalLB Arc extension and an ARP pool from the configured service VIP reservation. The Runtime provider object ID comes from the organizer, not a Graph lookup using the Client managed identity. +IncludeReferenceVm defaults to true and creates sov-ref-vm for participant inspection, +with a separate local credential, guest management and periodic update assessment. +Operations run without confirmation under the default PowerShell preference; +use Confirm to request prompts or WhatIf to preview changes. +Assumes the standard greenfield LocalBox network configuration. Configured address +ranges and known reservations are validated; live DHCP leases are not inspected. +New image imports resolve latest from this cluster's Edge Marketplace catalog, +not the Azure Compute catalog; existing working images are reused unchanged. Dot-source this file to load its functions without running provisioning. #> [CmdletBinding(SupportsShouldProcess)] @@ -24,6 +32,8 @@ param( [string]$AksAdminGroupObjectId, [string]$KubernetesRuntimeObjectId, [switch]$SkipAks, + [switch]$IncludeReferenceVm = $true, + [pscredential]$ReferenceVmCredential, [pscredential]$NodeCredential, [string]$VmSwitchName, [string]$ImageName = '2025-datacenter-azure-edition-smalldisk-01', @@ -37,7 +47,6 @@ param( [ValidateRange(1, 10)][int]$NodeCount = 3, [ValidateRange(512, 4096)][int]$StorageSizeGB = 1024, [switch]$RemoveUserStorage2, - [switch]$AddressReservationsConfirmed, [ValidateRange(1, 720)][int]$TimeoutMinutes = 360, [string]$ManifestPath = 'C:\LocalBox\sovereign-localbox.json' ) @@ -202,9 +211,215 @@ function Get-LocalBoxResource { elseif ($Id -match '/providers/Microsoft\.KubernetesRuntime/loadBalancers/') { $arguments += @('--api-version', '2024-08-01') } + elseif ($Id -match '/providers/Microsoft\.AzureStackHCI/virtualMachineInstances/') { + $arguments += @('--api-version', '2024-01-01') + } + elseif ($Id -match '/providers/Microsoft\.HybridCompute/machines/') { + $arguments += @('--api-version', '2024-07-10') + } Invoke-LocalBoxAz $arguments } +function New-LocalBoxVmTemplate { + param([switch]$EnablePeriodicAssessment) + $machineId = "[resourceId('Microsoft.HybridCompute/machines', parameters('name'))]" + $nicId = "[resourceId('Microsoft.AzureStackHCI/networkInterfaces', concat(parameters('name'), '-nic'))]" + $extendedLocation = @{ type = 'CustomLocation'; name = "[parameters('customLocationId')]" } + $machine = @{ + type = 'Microsoft.HybridCompute/machines'; apiVersion = '2023-10-03-preview' + name = "[parameters('name')]"; location = "[parameters('location')]" + kind = 'HCI'; identity = @{ type = 'SystemAssigned' }; tags = "[parameters('tags')]" + } + if ($EnablePeriodicAssessment) { + $machine.apiVersion = '2024-07-10' + $machine.properties = @{ osProfile = @{ windowsConfiguration = @{ + patchSettings = @{ assessmentMode = 'AutomaticByPlatform' } + } } } + } + @{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' + contentVersion = '1.0.0.0' + parameters = @{ + name = @{ type = 'string'; minLength = 1; maxLength = 15 }; location = @{ type = 'string' } + customLocationId = @{ type = 'string' }; imageId = @{ type = 'string' } + networkId = @{ type = 'string' }; storageId = @{ type = 'string' } + processors = @{ type = 'int' }; memoryMB = @{ type = 'int' } + adminUsername = @{ type = 'string' }; adminPassword = @{ type = 'securestring' } + tags = @{ type = 'object' } + } + resources = @( + $machine + @{ + type = 'Microsoft.AzureStackHCI/networkInterfaces'; apiVersion = '2024-01-01' + name = "[concat(parameters('name'), '-nic')]"; location = "[parameters('location')]" + extendedLocation = $extendedLocation; tags = "[parameters('tags')]" + properties = @{ ipConfigurations = @(@{ + name = 'ipconfig1'; properties = @{ subnet = @{ id = "[parameters('networkId')]" } } + }) } + } + @{ + type = 'Microsoft.AzureStackHCI/virtualMachineInstances'; apiVersion = '2024-01-01' + name = 'default'; scope = $machineId; extendedLocation = $extendedLocation + dependsOn = @($machineId, $nicId) + properties = @{ + hardwareProfile = @{ vmSize = 'Custom'; processors = "[parameters('processors')]"; memoryMB = "[parameters('memoryMB')]" } + osProfile = @{ + adminUsername = "[parameters('adminUsername')]"; adminPassword = "[parameters('adminPassword')]" + computerName = "[parameters('name')]" + windowsConfiguration = @{ provisionVMAgent = $true; provisionVMConfigAgent = $true } + } + storageProfile = @{ vmConfigStoragePathId = "[parameters('storageId')]"; imageReference = @{ id = "[parameters('imageId')]" } } + networkProfile = @{ networkInterfaces = @(@{ id = $nicId }) } + } + } + ) + } +} + +function Submit-LocalBoxVM { + param([hashtable]$State, [hashtable]$Entry, [pscredential]$Credential, [hashtable]$Tags, [switch]$EnablePeriodicAssessment) + if ($Entry.Name -notmatch '^(?=.{1,15}$)(?=.*[a-zA-Z])[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$') { + throw 'VM computer name must contain 1-15 letters, digits or internal hyphens, and cannot be all numeric.' + } + $directory = Join-Path ([IO.Path]::GetTempPath()) "lbvm-$([guid]::NewGuid())" + try { + if ($IsWindows) { + New-Item -ItemType Directory -Path $directory -ErrorAction Stop | Out-Null + $acl = Get-Acl -LiteralPath $directory + $acl.SetAccessRuleProtection($true, $false) + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $rule = [Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow') + $acl.SetAccessRule($rule) + Set-Acl -LiteralPath $directory -AclObject $acl -ErrorAction Stop + } + else { [IO.Directory]::CreateDirectory($directory, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) | Out-Null } + $templatePath = Join-Path $directory 'vm.json' + New-LocalBoxVmTemplate -EnablePeriodicAssessment:$EnablePeriodicAssessment | ConvertTo-Json -Depth 30 | + Set-Content -LiteralPath $templatePath -ErrorAction Stop + $values = @{ + name = $Entry.Name; location = $State.Location; customLocationId = $State.CustomLocationId + imageId = $State.ImageId; networkId = $State.NetworkId; storageId = $State.StorageId + processors = $State.ProcessorCount; memoryMB = $State.MemoryMB + adminUsername = $Credential.UserName; adminPassword = $Credential.GetNetworkCredential().Password + tags = $Tags + } + $parameters = @{} + foreach ($key in $values.Keys) { $parameters[$key] = @{ value = $values[$key] } } + $parameterPath = Join-Path $directory 'parameters.json' + @{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'; contentVersion = '1.0.0.0'; parameters = $parameters } | + ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $parameterPath -ErrorAction Stop + $arguments = @('--subscription', $State.SubscriptionId, '--resource-group', $State.ResourceGroupName, + '--name', $Entry.Name, '--template-file', $templatePath, '--parameters', "@$parameterPath") + $preview = Invoke-LocalBoxAz (@('deployment', 'group', 'what-if') + $arguments + @('--no-pretty-print', '--result-format', 'ResourceIdOnly')) + $machineId = "/subscriptions/$($State.SubscriptionId)/resourceGroups/$($State.ResourceGroupName)/providers/Microsoft.HybridCompute/machines/$($Entry.Name)" + $allowed = @($machineId, "$machineId/providers/Microsoft.AzureStackHCI/virtualMachineInstances/default", + "/subscriptions/$($State.SubscriptionId)/resourceGroups/$($State.ResourceGroupName)/providers/Microsoft.AzureStackHCI/networkInterfaces/$($Entry.Name)-nic") + $creates = @($preview.changes | Where-Object changeType -eq 'Create') + $unsafe = @($preview.changes | Where-Object { + ($_.resourceId -iin $allowed -and $_.changeType -ne 'Create') -or + ($_.resourceId -inotIn $allowed -and $_.changeType -notin @('Ignore', 'NoChange')) + }) + if ($preview.status -ne 'Succeeded' -or $creates.Count -ne 3 -or + @($creates.resourceId | Sort-Object -Unique).Count -ne 3 -or $unsafe.Count) { + $Entry.SubmissionError = 'ARM what-if did not confirm exactly the three new VM resources with no changes to existing resources.' + throw $Entry.SubmissionError + } + Invoke-LocalBoxAz (@('deployment', 'group', 'create') + $arguments + @('--no-wait')) -NoOutput + } + catch { throw "VM submission failed for $($Entry.Name). Inspect its ARM deployment; resources may still be provisioning. CLI details are withheld to protect the VM credential." } + finally { + $values = $null + $parameters = $null + if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction Stop } + } +} + +function Get-LocalBoxReferenceVmReadiness { + param([string]$MachineId) + $machine = Get-LocalBoxResource $MachineId + $instance = Get-LocalBoxResource "$MachineId/providers/Microsoft.AzureStackHCI/virtualMachineInstances/default" + if ($machine.properties -isnot [System.Collections.IDictionary] -or -not $instance.properties.status.powerState) { + throw "Reference VM $MachineId returned incomplete machine/power data. Inspect its provisioning state." + } + $extensions = @(Get-LocalBoxArmCollection "https://management.azure.com$MachineId/extensions?api-version=2024-07-10") + $mde = @($extensions | Where-Object { $_.properties.publisher -eq 'Microsoft.Azure.AzureDefenderForServers' -and $_.properties.type -eq 'MDE.Windows' }) + if ($mde.Count -gt 1 -or ($mde.Count -eq 1 -and -not $mde[0].properties.provisioningState)) { + throw "Reference VM $MachineId returned unexpected MDE extension status." + } + @{ + ObservedAt = [datetime]::UtcNow.ToString('o') + PowerState = $instance.properties.status.powerState + ArcStatus = if ($machine.properties.status) { $machine.properties.status } else { 'NotReported' } + MdeProvisioningState = if ($mde.Count) { $mde[0].properties.provisioningState } else { 'Pending' } + PeriodicAssessmentMode = $machine.properties.osProfile.windowsConfiguration.patchSettings.assessmentMode + DefenderAssessments = 'NotVerified' + UpdateAssessmentResults = 'NotVerified' + } +} + +function Sync-LocalBoxReferenceVm { + [CmdletBinding(SupportsShouldProcess)] + param([hashtable]$Settings, [string]$Location, [string]$CustomLocationId, [string]$ImageId, [string]$NetworkId, [string]$StorageId) + if (-not $Settings.IncludeReferenceVm) { return } + $name = 'sov-ref-vm' + $scope = "/subscriptions/$($Settings.SubscriptionId)/resourceGroups/$($Settings.ResourceGroupName)" + $machineId = "$scope/providers/Microsoft.HybridCompute/machines/$name" + $nicId = "$scope/providers/Microsoft.AzureStackHCI/networkInterfaces/$name-nic" + $instanceId = "$machineId/providers/Microsoft.AzureStackHCI/virtualMachineInstances/default" + $tags = @{ MicroHackPurpose = 'LocalBoxReferenceVM'; SecurityControl = 'Ignore'; CostControl = 'Ignore' } + $expected = @{ + extendedLocation = @{ name = $CustomLocationId } + properties = @{ + hardwareProfile = @{ vmSize = 'Custom'; processors = 2; memoryMB = 4096 } + osProfile = @{ computerName = $name; windowsConfiguration = @{ provisionVMAgent = $true; provisionVMConfigAgent = $true } } + storageProfile = @{ vmConfigStoragePathId = $StorageId; imageReference = @{ id = $ImageId } } + networkProfile = @{ networkInterfaces = @(@{ id = $nicId }) } + } + } + $machineExpected = @{ kind = 'HCI'; location = $Location; tags = $tags; identity = @{ type = 'SystemAssigned' } + properties = @{ osProfile = @{ windowsConfiguration = @{ patchSettings = @{ assessmentMode = 'AutomaticByPlatform' } } } } } + $nicExpected = @{ location = $Location; extendedLocation = @{ name = $CustomLocationId }; tags = $tags + properties = @{ ipConfigurations = @(@{ name = 'ipconfig1'; properties = @{ subnet = @{ id = $NetworkId } } }) } } + $resources = @(Invoke-LocalBoxAz @('resource', 'list', '--subscription', $Settings.SubscriptionId, '--resource-group', $Settings.ResourceGroupName)) + $existing = @($resources | Where-Object id -ieq $machineId) + if ($existing.Count) { + Assert-LocalBoxProperties (Get-LocalBoxResource $machineId) $machineExpected + Assert-LocalBoxProperties (Get-LocalBoxResource $nicId) $nicExpected + Assert-LocalBoxProperties (Get-LocalBoxResource $instanceId) $expected + Write-Host "Reusing reference VM $name without changing credentials or configuration." + } + elseif (@($resources | Where-Object { $_.id -iin @($nicId, $instanceId) }).Count) { + throw 'Partial or conflicting reference-VM resources exist. Inspect them before retrying; preparation will not overwrite or delete them.' + } + elseif ($PSCmdlet.ShouldProcess($machineId, 'Create shared reference VM (2 vCPU, 4 GiB RAM, guest management and periodic update assessment)')) { + $credential = $Settings.ReferenceVmCredential + if (-not $credential) { + $credential = Get-Credential -UserName 'localadmin' -Message 'Separate local administrator for sov-ref-vm; retain securely and do not share with participants' + } + if (-not $credential) { throw 'A separate reference-VM credential is required; nested domain credentials are not reused.' } + $state = @{ + SubscriptionId = $Settings.SubscriptionId; ResourceGroupName = $Settings.ResourceGroupName; Location = $Location + CustomLocationId = $CustomLocationId; ImageId = $ImageId; NetworkId = $NetworkId; StorageId = $StorageId + ProcessorCount = 2; MemoryMB = 4096 + } + Submit-LocalBoxVM -State $state -Entry @{ Name = $name } -Credential $credential -Tags $tags -EnablePeriodicAssessment + Wait-LocalBoxResource "$scope/providers/Microsoft.Resources/deployments/$name" -TimeoutSeconds ($Settings.TimeoutMinutes * 60) | Out-Null + } + else { return } + if ($WhatIfPreference) { return } + Assert-LocalBoxProperties (Wait-LocalBoxResource $instanceId -TimeoutSeconds ($Settings.TimeoutMinutes * 60)) $expected + Assert-LocalBoxProperties (Get-LocalBoxResource $machineId) $machineExpected + Assert-LocalBoxProperties (Get-LocalBoxResource $nicId) $nicExpected + $readiness = Get-LocalBoxReferenceVmReadiness $machineId + Write-Host "Reference VM: power=$($readiness.PowerState), Arc=$($readiness.ArcStatus), MDE=$($readiness.MdeProvisioningState)." + Write-Warning 'Reference VM is for inspection only, not enforced read-only by RBAC. Verify Defender and Update Manager assessment results before the event; provisioning does not prove assessment completion.' + @{ + Name = $name; MachineId = $machineId; InstanceId = $instanceId; NicId = $nicId + Expected = $expected; MachineExpected = $machineExpected; NicExpected = $nicExpected + Readiness = $readiness + } +} + function Resolve-LocalBoxStoragePath { param( [array]$Resources, @@ -276,6 +491,91 @@ function Sync-LocalBoxResource { } } +function Resolve-LocalBoxImageVersion { + param( + [Parameter(Mandatory)][string]$ClusterId, + [Parameter(Mandatory)][hashtable]$Identifier, + [ValidateNotNullOrEmpty()][string]$RequestedVersion = 'latest' + ) + if ($ClusterId -notmatch '^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.AzureStackHCI/clusters/[^/]+$') { + throw 'Image version discovery requires the Azure Local cluster resource ID.' + } + foreach ($field in @('publisher', 'offer', 'sku')) { + if ([string]::IsNullOrWhiteSpace($Identifier[$field])) { throw "Image identifier is missing $field." } + } + $offerId = [uri]::EscapeDataString("$($Identifier.publisher):$($Identifier.offer)") + $offer = Invoke-LocalBoxAz @('rest', '--method', 'get', '--url', + "https://management.azure.com$ClusterId/providers/Microsoft.EdgeMarketplace/offers/${offerId}?api-version=2023-08-01") + if ($offer.properties.marketplaceSkus -isnot [System.Collections.IList]) { + throw 'Edge Marketplace returned no valid SKU catalog for this cluster; no image import or deletion was attempted.' + } + $skus = @($offer.properties.marketplaceSkus | Where-Object marketplaceSkuId -eq $Identifier.sku) + if ($skus.Count -ne 1 -or $skus[0].marketplaceSkuVersions -isnot [System.Collections.IList] -or + $skus[0].marketplaceSkuVersions.Count -eq 0) { + throw "Edge Marketplace does not advertise one usable version list for SKU '$($Identifier.sku)' on this cluster." + } + $versions = @($skus[0].marketplaceSkuVersions | ForEach-Object { + $parsed = $null + if (-not [version]::TryParse($_.name, [ref]$parsed)) { throw 'Edge Marketplace returned an invalid image version.' } + [string]$_.name + }) + if ($RequestedVersion -eq 'latest') { + $selected = $versions | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 + } + elseif ($RequestedVersion -in $versions) { $selected = $RequestedVersion } + else { + throw "Image version '$RequestedVersion' is not advertised by this cluster's Edge Marketplace catalog for '$($Identifier.sku)'. Select a listed version or use latest." + } + Write-Host "Using Edge Marketplace image version $selected for $($Identifier.sku)." + return $selected +} + +function Sync-LocalBoxImage { + [CmdletBinding(SupportsShouldProcess)] + param( + [string]$Id, [hashtable]$Expected, [string[]]$CreateArguments, + [Parameter(Mandatory)][string]$ClusterId, [int]$TimeoutSeconds = 21600 + ) + if ($Id -notmatch '^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\.AzureStackHCI/marketplaceGalleryImages/[^/]+$') { + throw 'Image recovery requires an exact Azure Local Marketplace image resource ID.' + } + $listArguments = @('resource', 'list', '--subscription', $Matches[1], '--resource-group', $Matches[2]) + $existing = @((Invoke-LocalBoxAz $listArguments) | Where-Object id -ieq $Id) + if ($existing.Count) { + $image = Get-LocalBoxResource $Id + if ($image.properties.provisioningState -notin @('Failed', 'Deleting')) { + return Sync-LocalBoxResource -Id $Id -Expected $Expected -CreateArguments $CreateArguments -TimeoutSeconds $TimeoutSeconds -WhatIf:$WhatIfPreference + } + Assert-LocalBoxProperties $image $Expected + } + $requestedVersion = if ($Expected.properties.Contains('version')) { $Expected.properties.version.name } else { 'latest' } + $version = Resolve-LocalBoxImageVersion -ClusterId $ClusterId -Identifier $Expected.properties.identifier -RequestedVersion $requestedVersion + $createExpected = $Expected.Clone() + $createExpected.properties = $Expected.properties.Clone() + $createExpected.properties.version = @{ name = $version } + if ($existing.Count) { + if ($image.properties.provisioningState -eq 'Failed') { + Write-Warning "Image import failed for $Id. Retrying with catalog-listed version $version." + if (-not $PSCmdlet.ShouldProcess($Id, 'Delete matching Failed image and retry import once')) { + if ($WhatIfPreference) { return } + throw 'Failed-image recovery was declined. No image was deleted; preparation cannot continue with this image.' + } + Invoke-LocalBoxAz @('resource', 'delete', '--ids', $Id, '--api-version', '2024-01-01') -TimeoutSeconds $TimeoutSeconds -NoOutput + } + if ($WhatIfPreference) { + Write-Host "Image deletion is in progress; a real run will wait for removal before importing $Id." + return + } + $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds) + while (@((Invoke-LocalBoxAz $listArguments) | Where-Object id -ieq $Id).Count) { + if ([datetime]::UtcNow -ge $deadline) { throw "Timed out waiting for image deletion: $Id. No replacement import was started." } + Write-Host "Waiting for failed image removal: $Id..." + Start-Sleep -Seconds 15 + } + } + Sync-LocalBoxResource -Id $Id -Expected $createExpected -CreateArguments ($CreateArguments + @('--version', $version)) -TimeoutSeconds $TimeoutSeconds -WhatIf:$WhatIfPreference +} + function Get-LocalBoxMetalLbPlan { param([Parameter(Mandatory)][hashtable]$Configuration, [Parameter(Mandatory)][string]$ClusterId) if ($ClusterId -notmatch '^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Kubernetes/connectedClusters/[^/]+$') { @@ -496,8 +796,31 @@ function Wait-LocalBoxStorageReady { } while ($true) } +function Get-LocalBoxPartitionState { + param([string]$Node, [pscredential]$Credential) + $state = Invoke-Command -VMName $Node -Credential $Credential -ErrorAction Stop -ScriptBlock { + $ErrorActionPreference = 'Stop' + $partitions = @(Get-Volume -FileSystemLabel 'UserStorage_1' | Get-Partition) + if ($partitions.Count -ne 1) { throw 'Cannot uniquely resolve the UserStorage_1 partition.' } + $partition = $partitions[0] + $disk = $partition | Get-Disk + if ($partition.Size -le 0 -or $partition.Offset -lt 0 -or $disk.Size -lt ($partition.Offset + $partition.Size)) { + throw 'Invalid UserStorage_1 disk/partition sizes; refusing partition expansion.' + } + # Ignore the alignment/GPT tail, without running the costly supported-size query on every rerun. + @{ + Size = $partition.Size + NeedsExpansion = ($disk.Size - $partition.Offset - $partition.Size) -gt 1MB + } + } + if ($state.NeedsExpansion -isnot [bool] -or $state.Size -le 0) { + throw 'No valid UserStorage_1 partition state was returned; preparation cannot safely skip or perform expansion.' + } + return $state +} + function Initialize-LocalBoxStorage { - [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [CmdletBinding(SupportsShouldProcess)] param( [hashtable]$Configuration, [pscredential]$Credential, [int]$DesiredSizeGB, [string]$Subscription, [string]$ResourceGroup, [switch]$RemoveSecondary @@ -560,15 +883,24 @@ function Initialize-LocalBoxStorage { throw 'Insufficient supported storage capacity including resiliency and reserve. No resize performed.' } Resize-VirtualDisk -FriendlyName 'UserStorage_1' -Size $target + Update-HostStorageCache -ErrorAction Stop } } - if ($PSCmdlet.ShouldProcess('UserStorage_1 partition', 'Extend partition to existing virtual disk size')) { + $partitionState = Get-LocalBoxPartitionState $nodes[0] $Credential + $needsExpansion = $partitionState.NeedsExpansion -or ($WhatIfPreference -and $state.Size -lt ([uint64]$DesiredSizeGB * 1GB)) + if ($needsExpansion -and $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 } } + if ((Get-LocalBoxPartitionState $nodes[0] $Credential).NeedsExpansion) { + throw 'UserStorage_1 still has unexpanded partition space. Inspect the disk/partition state before retrying.' + } + } + elseif (-not $needsExpansion) { + Write-Host 'UserStorage_1 partition already fills the disk; no partition expansion needed.' } } @@ -593,9 +925,6 @@ function Invoke-LocalBoxPreparation { 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 VM/node pools and the AKS service VIP reservation against DHCP leases/exclusions and static reservations, then pass -AddressReservationsConfirmed. The script does not alter DHCP/router configuration.' - } $Settings.NodeCredential = Resolve-LocalBoxNodeCredential -Configuration $config -Credential $Settings.NodeCredential $previousConfig = $env:AZURE_CONFIG_DIR $previousExtensions = $env:AZURE_EXTENSION_DIR @@ -657,8 +986,6 @@ function Invoke-LocalBoxPreparation { } 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.' } $storage = Resolve-LocalBoxStoragePath -Resources $resources -Name UserStorage1 -CustomLocationId $custom.id $storageId = $storage.id $imageId = "$scope/providers/Microsoft.AzureStackHCI/marketplaceGalleryImages/$($Settings.ImageName)" @@ -671,17 +998,6 @@ function Invoke-LocalBoxPreparation { $location = $custom.location $timeout = $Settings.TimeoutMinutes * 60 $common = @('--subscription', $Settings.SubscriptionId, '--resource-group', $Settings.ResourceGroupName, '--location', $location, '--custom-location', $custom.id) - $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 } } - 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)) $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 } @@ -739,6 +1055,21 @@ function Invoke-LocalBoxPreparation { Sync-LocalBoxMetalLb -ClusterId $aksId -Plan $metalLb -RuntimeObjectId $Settings.KubernetesRuntimeObjectId ` -TimeoutSeconds $timeout -WhatIf:$WhatIfPreference } + $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.' } + $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 } } + Write-Host "Preparing VM image $($Settings.ImageName); download can take several hours..." + $image = Sync-LocalBoxImage -Id $imageId -Expected $imageExpected -ClusterId $cluster[0].id -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', '--storage-path-id', $storageId)) + $referenceVm = Sync-LocalBoxReferenceVm -Settings $Settings -Location $location -CustomLocationId $custom.id ` + -ImageId $imageId -NetworkId $networks[0].Id -StorageId $storageId -WhatIf:$WhatIfPreference $manifest = @{ SubscriptionId = $Settings.SubscriptionId; ResourceGroupName = $Settings.ResourceGroupName; CustomLocationId = $custom.id ClusterId = $cluster[0].id; BridgeId = $bridge.id; ExtensionId = $extensions[0]; StorageId = $storageId @@ -749,6 +1080,14 @@ function Invoke-LocalBoxPreparation { AksPreparationSkipped = [bool]$Settings.SkipAks MetalLb = $metalLb } + if ($referenceVm) { $manifest.ReferenceVm = $referenceVm } + elseif (Test-Path -LiteralPath $Settings.ManifestPath) { + $previousManifest = Get-Content -LiteralPath $Settings.ManifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable + if ($previousManifest.SubscriptionId -ieq $Settings.SubscriptionId -and + $previousManifest.ResourceGroupName -ieq $Settings.ResourceGroupName -and $previousManifest.ReferenceVm) { + $manifest.ReferenceVm = $previousManifest.ReferenceVm + } + } if ($PSCmdlet.ShouldProcess($Settings.ManifestPath, 'Write nonsecret preparation manifest')) { $manifest | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Settings.ManifestPath -Encoding utf8 } @@ -770,10 +1109,11 @@ if ($MyInvocation.InvocationName -ne '.') { SubscriptionId = $SubscriptionId; ResourceGroupName = $ResourceGroupName; ConfigPath = $ConfigPath AksAdminGroupObjectId = $AksAdminGroupObjectId; KubernetesRuntimeObjectId = $KubernetesRuntimeObjectId SkipAks = [bool]$SkipAks; NodeCredential = $NodeCredential; VmSwitchName = $VmSwitchName + IncludeReferenceVm = [bool]$IncludeReferenceVm; ReferenceVmCredential = $ReferenceVmCredential 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 + RemoveUserStorage2 = [bool]$RemoveUserStorage2 TimeoutMinutes = $TimeoutMinutes; ManifestPath = $ManifestPath } Invoke-LocalBoxPreparation -Settings $settings diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-localbox-capacity.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-localbox-capacity.ps1 index 3b125e7ae..206fd7d8f 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-localbox-capacity.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/test-localbox-capacity.ps1 @@ -135,54 +135,6 @@ function Assert-LocalBoxCapacityHeadroom { } } -function New-LocalBoxCapacityTemplate { - $machineId = "[resourceId('Microsoft.HybridCompute/machines', parameters('name'))]" - $nicId = "[resourceId('Microsoft.AzureStackHCI/networkInterfaces', concat(parameters('name'), '-nic'))]" - $extendedLocation = @{ type = 'CustomLocation'; name = "[parameters('customLocationId')]" } - @{ - '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' - contentVersion = '1.0.0.0' - parameters = @{ - name = @{ type = 'string'; maxLength = 15 }; location = @{ type = 'string' } - customLocationId = @{ type = 'string' }; imageId = @{ type = 'string' } - networkId = @{ type = 'string' }; storageId = @{ type = 'string' } - processors = @{ type = 'int' }; memoryMB = @{ type = 'int' } - adminUsername = @{ type = 'string' }; adminPassword = @{ type = 'securestring' } - tags = @{ type = 'object' } - } - resources = @( - @{ - type = 'Microsoft.HybridCompute/machines'; apiVersion = '2023-10-03-preview' - name = "[parameters('name')]"; location = "[parameters('location')]" - kind = 'HCI'; identity = @{ type = 'SystemAssigned' }; tags = "[parameters('tags')]" - } - @{ - type = 'Microsoft.AzureStackHCI/networkInterfaces'; apiVersion = '2024-01-01' - name = "[concat(parameters('name'), '-nic')]"; location = "[parameters('location')]" - extendedLocation = $extendedLocation; tags = "[parameters('tags')]" - properties = @{ ipConfigurations = @(@{ - name = 'ipconfig1'; properties = @{ subnet = @{ id = "[parameters('networkId')]" } } - }) } - } - @{ - type = 'Microsoft.AzureStackHCI/virtualMachineInstances'; apiVersion = '2024-01-01' - name = 'default'; scope = $machineId; extendedLocation = $extendedLocation - dependsOn = @($machineId, $nicId) - properties = @{ - hardwareProfile = @{ vmSize = 'Custom'; processors = "[parameters('processors')]"; memoryMB = "[parameters('memoryMB')]" } - osProfile = @{ - adminUsername = "[parameters('adminUsername')]"; adminPassword = "[parameters('adminPassword')]" - computerName = "[parameters('name')]" - windowsConfiguration = @{ provisionVMAgent = $true; provisionVMConfigAgent = $true } - } - storageProfile = @{ vmConfigStoragePathId = "[parameters('storageId')]"; imageReference = @{ id = "[parameters('imageId')]" } } - networkProfile = @{ networkInterfaces = @(@{ id = $nicId }) } - } - } - ) - } -} - function Save-LocalBoxCapacityState { param([hashtable]$State, [string]$Path) $State.UpdatedAt = [datetime]::UtcNow.ToString('o') @@ -231,56 +183,8 @@ function Assert-LocalBoxCapacityOwnership { function Submit-LocalBoxCapacityVM { param([hashtable]$State, [hashtable]$Entry, [pscredential]$Credential) - $directory = Join-Path ([IO.Path]::GetTempPath()) "lbcap-$([guid]::NewGuid())" - try { - if ($IsWindows) { - New-Item -ItemType Directory -Path $directory -ErrorAction Stop | Out-Null - $acl = Get-Acl -LiteralPath $directory - $acl.SetAccessRuleProtection($true, $false) - $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User - $rule = [Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow') - $acl.SetAccessRule($rule) - Set-Acl -LiteralPath $directory -AclObject $acl -ErrorAction Stop - } - else { [IO.Directory]::CreateDirectory($directory, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) | Out-Null } - $templatePath = Join-Path $directory 'vm.json' - New-LocalBoxCapacityTemplate | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $templatePath -ErrorAction Stop - $values = @{ - name = $Entry.Name; location = $State.Location; customLocationId = $State.CustomLocationId - imageId = $State.ImageId; networkId = $State.NetworkId; storageId = $State.StorageId - processors = $State.ProcessorCount; memoryMB = $State.MemoryMB - adminUsername = $Credential.UserName; adminPassword = $Credential.GetNetworkCredential().Password - tags = @{ MicroHackPurpose = 'LocalBoxCapacityTest'; MicroHackRunId = $State.RunId } - } - $parameters = @{} - foreach ($key in $values.Keys) { $parameters[$key] = @{ value = $values[$key] } } - $parameterPath = Join-Path $directory 'parameters.json' - @{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'; contentVersion = '1.0.0.0'; parameters = $parameters } | - ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $parameterPath -ErrorAction Stop - $arguments = @('--subscription', $State.SubscriptionId, '--resource-group', $State.ResourceGroupName, - '--name', $Entry.Name, '--template-file', $templatePath, '--parameters', "@$parameterPath") - $preview = Invoke-LocalBoxAz (@('deployment', 'group', 'what-if') + $arguments + @('--no-pretty-print', '--result-format', 'ResourceIdOnly')) - $machineId = "/subscriptions/$($State.SubscriptionId)/resourceGroups/$($State.ResourceGroupName)/providers/Microsoft.HybridCompute/machines/$($Entry.Name)" - $allowed = @($machineId, "$machineId/providers/Microsoft.AzureStackHCI/virtualMachineInstances/default", - "/subscriptions/$($State.SubscriptionId)/resourceGroups/$($State.ResourceGroupName)/providers/Microsoft.AzureStackHCI/networkInterfaces/$($Entry.Name)-nic") - $creates = @($preview.changes | Where-Object changeType -eq 'Create') - $unsafe = @($preview.changes | Where-Object { - ($_.resourceId -iin $allowed -and $_.changeType -ne 'Create') -or - ($_.resourceId -inotIn $allowed -and $_.changeType -notin @('Ignore', 'NoChange')) - }) - if ($preview.status -ne 'Succeeded' -or $creates.Count -ne 3 -or - @($creates.resourceId | Sort-Object -Unique).Count -ne 3 -or $unsafe.Count) { - $Entry.SubmissionError = 'ARM what-if did not confirm exactly the three new test resources with no changes to existing resources.' - throw $Entry.SubmissionError - } - Invoke-LocalBoxAz (@('deployment', 'group', 'create') + $arguments + @('--no-wait')) -NoOutput - } - catch { throw "VM submission failed for $($Entry.Name). Inspect its ARM deployment; resources may still be provisioning. CLI details are withheld to protect the VM credential." } - finally { - $values = $null - $parameters = $null - if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction Stop } - } + Submit-LocalBoxVM -State $State -Entry $Entry -Credential $Credential ` + -Tags @{ MicroHackPurpose = 'LocalBoxCapacityTest'; MicroHackRunId = $State.RunId } } function Update-LocalBoxCapacityVM { 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 beeb7d1be..2e3f58c9f 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 @@ -72,6 +72,28 @@ Describe 'Shared LocalBox control plane' { } } +if ($LocalBox.ReferenceVm) { + Describe 'Optional shared reference VM' { + It 'matches the organizer-owned VM, network and guest-management configuration' { + $reference = $LocalBox.ReferenceVm + $reference.MachineId | Should -Be "/subscriptions/$($LocalBox.SubscriptionId)/resourceGroups/$($LocalBox.ResourceGroupName)/providers/Microsoft.HybridCompute/machines/sov-ref-vm" + $reference.InstanceId | Should -Be "$($reference.MachineId)/providers/Microsoft.AzureStackHCI/virtualMachineInstances/default" + Assert-LocalBoxProperties (Get-LocalBoxResource $reference.MachineId) $reference.MachineExpected + Assert-LocalBoxProperties (Get-LocalBoxResource $reference.NicId) $reference.NicExpected + Assert-LocalBoxProperties (Wait-LocalBoxResource $reference.InstanceId -TimeoutSeconds $TimeoutSeconds) $reference.Expected + } + It 'is running and Arc-connected with successful MDE installation (not proof of completed assessments)' { + Wait-SovereignCheck -TimeoutSeconds $TimeoutSeconds -Check { + $readiness = Get-LocalBoxReferenceVmReadiness $LocalBox.ReferenceVm.MachineId + $readiness.PowerState | Should -Be 'Running' + $readiness.ArcStatus | Should -Be 'Connected' + $readiness.MdeProvisioningState | Should -Be 'Succeeded' + $readiness.PeriodicAssessmentMode | Should -Be 'AutomaticByPlatform' + } + } + } +} + 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' 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 e19272d60..4950a7b2b 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 @@ -154,7 +154,7 @@ Describe 'LocalBox capacity lifecycle' { Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[0] -eq 'resource' } } It 'uses a secure password parameter and explicitly targets the prepared storage and network' { - $template = New-LocalBoxCapacityTemplate + $template = New-LocalBoxVmTemplate $template.parameters.adminPassword.type | Should -Be 'securestring' $template.resources.Count | Should -Be 3 $vm = $template.resources | Where-Object type -eq 'Microsoft.AzureStackHCI/virtualMachineInstances' @@ -451,6 +451,12 @@ Describe 'LocalBox capacity secure submission' { Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { $Arguments[2] -eq 'create' -and $NoOutput -and $Arguments -contains '--no-wait' } foreach ($path in $paths) { Test-Path -LiteralPath $path | Should -BeFalse } } + It 'accepts the exact 15-character Windows computer-name limit' { + $entry.Name = 'abcdefghijklmno' + foreach ($change in $preview.changes) { $change.resourceId = $change.resourceId.Replace('lc-test-001', $entry.Name) } + Submit-LocalBoxCapacityVM $state $entry $credential + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { $Arguments[2] -eq 'create' } + } It 'rejects changes outside the three new VM resources' -TestCases @( @{ Field = 'changeType'; Value = 'Modify' } @{ Field = 'resourceId'; Value = '/subscriptions/test/resourceGroups/shared/providers/Microsoft.HybridCompute/machines/student' } @@ -500,6 +506,315 @@ Describe 'LocalBox capacity secure submission' { } } +Describe 'LocalBox preparation defaults' { + It 'does not expose the removed manual address-reservation acknowledgement' { + (Get-Command "$PSScriptRoot/../prepare-localbox.ps1").Parameters.ContainsKey('AddressReservationsConfirmed') | Should -BeFalse + ${function:Invoke-LocalBoxPreparation}.ToString() | Should -Not -Match 'AddressReservationsConfirmed' + } + It 'includes the reference VM when the switch is omitted' { + & { + . "$PSScriptRoot/../prepare-localbox.ps1" + [bool]$IncludeReferenceVm + } | Should -BeTrue + } + It 'accepts an explicit reference-VM opt-out' { + & { + . "$PSScriptRoot/../prepare-localbox.ps1" -IncludeReferenceVm:$false + [bool]$IncludeReferenceVm + } | Should -BeFalse + } + It 'keeps script-level Confirm and WhatIf available without prompting at the default threshold' { + $command = Get-Command "$PSScriptRoot/../prepare-localbox.ps1" + $binding = $command.ScriptBlock.Attributes | Where-Object { $_ -is [Management.Automation.CmdletBindingAttribute] } + $binding.SupportsShouldProcess | Should -BeTrue + [int]$binding.ConfirmImpact | Should -BeLessThan ([int][Management.Automation.ConfirmImpact]::High) + $command.Parameters.ContainsKey('Confirm') | Should -BeTrue + $command.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + It 'does not require default confirmation for , while preserving explicit confirmation and previews' -TestCases @( + @{ FunctionName = 'Initialize-LocalBoxStorage' } + @{ FunctionName = 'Sync-LocalBoxImage' } + @{ FunctionName = 'Sync-LocalBoxResource' } + @{ FunctionName = 'Sync-LocalBoxReferenceVm' } + @{ FunctionName = 'Invoke-LocalBoxPreparation' } + ) { + param($FunctionName) + $command = Get-Command $FunctionName + $binding = $command.ScriptBlock.Attributes | Where-Object { $_ -is [Management.Automation.CmdletBindingAttribute] } + $binding.SupportsShouldProcess | Should -BeTrue + [int]$binding.ConfirmImpact | Should -BeLessThan ([int][Management.Automation.ConfirmImpact]::High) + $command.Parameters.ContainsKey('Confirm') | Should -BeTrue + $command.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } +} + +Describe 'LocalBox greenfield address validation' { + BeforeAll { + $source = ${function:Invoke-LocalBoxPreparation}.ToString() + $start = $source.IndexOf('Test-LocalBoxAddressPool $config.vmIpPrefix') + $end = $source.IndexOf('$Settings.NodeCredential =', $start) + $addressChecks = [scriptblock]::Create($source.Substring($start, $end - $start)) + } + BeforeEach { + $Settings = @{ VmPoolStart = '192.168.200.10'; VmPoolEnd = '192.168.200.199' } + $config = @{ + vmIpPrefix = '192.168.200.0/24'; vmGateway = '192.168.200.1'; dcVLAN200IP = '192.168.200.205'; vmVLAN = 200 + AKSIPPrefix = '10.10.0.0/24'; AKSGWIP = '10.10.0.1'; AKSControlPlaneIP = '10.10.0.5'; AKSVLAN = 110 + AKSVIPStartIP = '10.10.0.10'; AKSVIPEndIP = '10.10.0.100'; AKSNodeStartIP = '10.10.0.101'; AKSNodeEndIP = '10.10.0.199' + } + } + It 'accepts the standard topology without a manual acknowledgement' { + { & $addressChecks } | Should -Not -Throw + } + It 'still rejects configured address conflicts' -TestCases @( + @{ Conflict = 'gateway'; Message = '*reserved address*' } + @{ Conflict = 'infrastructure'; Message = '*reserved address*' } + @{ Conflict = 'VIP overlap'; Message = '*overlaps reserved range*' } + @{ Conflict = 'shared VLAN'; Message = '*separate VLANs and subnets*' } + ) { + param($Conflict, $Message) + switch ($Conflict) { + 'gateway' { $Settings.VmPoolStart = '192.168.200.1' } + 'infrastructure' { $Settings.VmPoolEnd = '192.168.200.210' } + 'VIP overlap' { $config.AKSNodeStartIP = '10.10.0.100' } + 'shared VLAN' { $config.AKSVLAN = $config.vmVLAN } + } + { & $addressChecks } | Should -Throw $Message + } +} + +Describe 'Optional LocalBox reference VM' { + BeforeEach { + $settings = @{ SubscriptionId = 'test'; ResourceGroupName = 'shared'; IncludeReferenceVm = $true; TimeoutMinutes = 30 } + $scope = '/subscriptions/test/resourceGroups/shared' + $machineId = "$scope/providers/Microsoft.HybridCompute/machines/sov-ref-vm" + $nicId = "$scope/providers/Microsoft.AzureStackHCI/networkInterfaces/sov-ref-vm-nic" + $instanceId = "$machineId/providers/Microsoft.AzureStackHCI/virtualMachineInstances/default" + $tags = @{ MicroHackPurpose = 'LocalBoxReferenceVM'; SecurityControl = 'Ignore'; CostControl = 'Ignore' } + $machine = @{ kind = 'HCI'; location = 'westeurope'; tags = $tags; identity = @{ type = 'SystemAssigned' } + properties = @{ status = 'Connected'; osProfile = @{ windowsConfiguration = @{ patchSettings = @{ assessmentMode = 'AutomaticByPlatform' } } } } } + $instance = @{ + extendedLocation = @{ name = 'custom' } + properties = @{ + provisioningState = 'Succeeded'; status = @{ powerState = 'Running' } + hardwareProfile = @{ vmSize = 'Custom'; processors = 2; memoryMB = 4096 } + osProfile = @{ computerName = 'sov-ref-vm'; windowsConfiguration = @{ provisionVMAgent = $true; provisionVMConfigAgent = $true } } + storageProfile = @{ vmConfigStoragePathId = 'storage'; imageReference = @{ id = 'image' } } + networkProfile = @{ networkInterfaces = @(@{ id = $nicId }) } + } + } + $nic = @{ location = 'westeurope'; extendedLocation = @{ name = 'custom' }; tags = $tags + properties = @{ ipConfigurations = @(@{ name = 'ipconfig1'; properties = @{ subnet = @{ id = 'network' } } }) } } + $referenceArguments = @{ Settings = $settings; Location = 'westeurope'; CustomLocationId = 'custom'; ImageId = 'image'; NetworkId = 'network'; StorageId = 'storage' } + Mock Invoke-LocalBoxAz { @() } + Mock Get-LocalBoxResource { + if ($Id -eq $machineId) { return $machine } + if ($Id -eq $nicId) { return $nic } + if ($Id -eq $instanceId) { return $instance } + throw "Unexpected resource $Id" + } + Mock Wait-LocalBoxResource { + if ($Id -like '*/Microsoft.Resources/deployments/sov-ref-vm') { return @{ properties = @{ provisioningState = 'Succeeded' } } } + if ($Id -eq $instanceId) { return $instance } + throw "Unexpected wait $Id" + } + Mock Get-LocalBoxArmCollection { @() } + Mock Submit-LocalBoxVM {} + Mock Get-Credential { [pscredential]::new('localadmin', (ConvertTo-SecureString 'Mock!Password9-not-real' -AsPlainText -Force)) } + } + It 'does nothing when explicitly disabled' { + $settings.IncludeReferenceVm = $false + Sync-LocalBoxReferenceVm @referenceArguments | Should -BeNullOrEmpty + Should -Invoke Invoke-LocalBoxAz -Times 0 + Should -Invoke Get-Credential -Times 0 + Should -Invoke Submit-LocalBoxVM -Times 0 + } + It 'previews without credentials, provisioning, polling or a success-shaped manifest' { + Sync-LocalBoxReferenceVm @referenceArguments -WhatIf | Should -BeNullOrEmpty + Should -Invoke Get-Credential -Times 0 + Should -Invoke Submit-LocalBoxVM -Times 0 + Should -Invoke Wait-LocalBoxResource -Times 0 + } + It 'creates one short-named tagged VM using a separate credential and periodic assessment' { + $reference = Sync-LocalBoxReferenceVm @referenceArguments + $reference.Name | Should -Be 'sov-ref-vm' + $reference.Name.Length | Should -BeLessOrEqual 15 + $reference.MachineId | Should -Be $machineId + $reference.Readiness.ArcStatus | Should -Be 'Connected' + $reference.Readiness.MdeProvisioningState | Should -Be 'Pending' + $reference.Readiness.DefenderAssessments | Should -Be 'NotVerified' + $reference.Readiness.UpdateAssessmentResults | Should -Be 'NotVerified' + $reference | ConvertTo-Json -Depth 30 | Should -Not -Match 'Mock!Password|adminPassword|localadmin' + Should -Invoke Submit-LocalBoxVM -Times 1 -Exactly -ParameterFilter { + $Entry.Name -eq 'sov-ref-vm' -and $Credential.UserName -eq 'localadmin' -and + $State.ResourceGroupName -eq 'shared' -and $State.ProcessorCount -eq 2 -and $State.MemoryMB -eq 4096 -and + $EnablePeriodicAssessment -and $Tags.SecurityControl -eq 'Ignore' -and $Tags.CostControl -eq 'Ignore' + } + } + It 'uses a supplied credential without prompting' { + $settings.ReferenceVmCredential = [pscredential]::new('refadmin', (ConvertTo-SecureString 'Mock!Password9-not-real' -AsPlainText -Force)) + Sync-LocalBoxReferenceVm @referenceArguments | Out-Null + Should -Invoke Get-Credential -Times 0 + Should -Invoke Submit-LocalBoxVM -Times 1 -ParameterFilter { $Credential.UserName -eq 'refadmin' } + } + It 'reuses matching resources without resetting credentials or redeploying' { + Mock Invoke-LocalBoxAz { @(@{ id = $machineId }) } + Sync-LocalBoxReferenceVm @referenceArguments | Out-Null + Should -Invoke Submit-LocalBoxVM -Times 0 + Should -Invoke Get-Credential -Times 0 + } + It 'rejects an unrelated machine at the stable name' { + Mock Invoke-LocalBoxAz { @(@{ id = $machineId }) } + $machine.tags = @{ MicroHackPurpose = 'ParticipantVM' } + { Sync-LocalBoxReferenceVm @referenceArguments } | Should -Throw + Should -Invoke Submit-LocalBoxVM -Times 0 + } + It 'rejects changed hardware rather than resizing it' { + Mock Invoke-LocalBoxAz { @(@{ id = $machineId }) } + $instance.properties.hardwareProfile.memoryMB = 8192 + { Sync-LocalBoxReferenceVm @referenceArguments } | Should -Throw '*Conflicting*memoryMB*' + Should -Invoke Submit-LocalBoxVM -Times 0 + } + It 'rejects a partial deployment rather than overwriting its NIC' { + Mock Invoke-LocalBoxAz { @(@{ id = $nicId }) } + { Sync-LocalBoxReferenceVm @referenceArguments } | Should -Throw '*Partial or conflicting*' + Should -Invoke Submit-LocalBoxVM -Times 0 + } + It 'does not hide failed deployment polling' { + Mock Wait-LocalBoxResource { throw 'Deployment Failed' } + { Sync-LocalBoxReferenceVm @referenceArguments } | Should -Throw '*Deployment Failed*' + } + It 'reports actual disconnected guest status without declaring protection ready' { + $machine.properties.status = 'Disconnected' + (Get-LocalBoxReferenceVmReadiness $machineId).ArcStatus | Should -Be 'Disconnected' + } + It 'reports MDE extension state separately from assessment completion' -TestCases @( + @{ State = 'Creating' }; @{ State = 'Succeeded' }; @{ State = 'Failed' } + ) { + param($State) + Mock Get-LocalBoxArmCollection { @(@{ properties = @{ publisher = 'Microsoft.Azure.AzureDefenderForServers'; type = 'MDE.Windows'; provisioningState = $State } }) } + $readiness = Get-LocalBoxReferenceVmReadiness $machineId + $readiness.MdeProvisioningState | Should -Be $State + $readiness.DefenderAssessments | Should -Be 'NotVerified' + } + It 'reports an unreported guest status explicitly while initial onboarding is pending' { + $machine.properties.Remove('status') + (Get-LocalBoxReferenceVmReadiness $machineId).ArcStatus | Should -Be 'NotReported' + } + It 'surfaces malformed responses or failed extension reads instead of reporting readiness' { + $instance.properties.status.Remove('powerState') + { Get-LocalBoxReferenceVmReadiness $machineId } | Should -Throw '*incomplete machine/power data*' + $instance.properties.status.powerState = 'Running' + Mock Get-LocalBoxArmCollection { throw 'Forbidden' } + { Get-LocalBoxReferenceVmReadiness $machineId } | Should -Throw '*Forbidden*' + } + It 'only adds periodic assessment to reference templates and leaves capacity behavior unchanged' { + $template = New-LocalBoxVmTemplate -EnablePeriodicAssessment + $template.resources[0].apiVersion | Should -Be '2024-07-10' + $template.resources[0].properties.osProfile.windowsConfiguration.patchSettings.assessmentMode | Should -Be 'AutomaticByPlatform' + (New-LocalBoxVmTemplate).resources[0].ContainsKey('properties') | Should -BeFalse + $template.parameters.adminPassword.type | Should -Be 'securestring' + $template.parameters.name.maxLength | Should -Be 15 + $template.resources[2].properties.osProfile.computerName | Should -Be "[parameters('name')]" + } +} + +Describe 'LocalBox VM name validation' { + It 'rejects Windows-invalid names before making any Azure request' -TestCases @( + @{ Name = 'sixteenlettersxxx' }; @{ Name = '12345' }; @{ Name = '-bad' }; @{ Name = 'bad_' }; @{ Name = '' } + ) { + param($Name) + Mock Invoke-LocalBoxAz { throw 'Unexpected Azure request' } + { Submit-LocalBoxVM -State @{} -Entry @{ Name = $Name } } | Should -Throw '*1-15*' + Should -Invoke Invoke-LocalBoxAz -Times 0 + } +} + +Describe 'Reference VM preparation wiring and manifest' { + BeforeAll { + $preparation = [scriptblock]::Create(${function:Invoke-LocalBoxPreparation}.ToString()).Ast + $invokeStep = [scriptblock]::Create($preparation.Find({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -eq '$referenceVm' + }, $true).Extent.Text) + $manifestStep = [scriptblock]::Create($preparation.Find({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and $node.Clauses[0].Item1.Extent.Text -eq '$referenceVm' + }, $true).Extent.Text) + } + BeforeEach { + $Settings = @{ SubscriptionId = 'test'; ResourceGroupName = 'shared'; ManifestPath = "$TestDrive/manifest.json"; IncludeReferenceVm = $true } + $manifest = @{} + $referenceVm = $null + $location = 'australiaeast' + $custom = @{ id = 'custom' } + $imageId = 'image' + $networks = @(@{ Id = 'vm-network' }, @{ Id = 'aks-network' }) + $storageId = 'storage' + } + It 'wires the enabled reference VM to the prepared image and VM network, not the AKS network' { + Mock Sync-LocalBoxReferenceVm { @{ Name = 'sov-ref-vm' } } + . $invokeStep + . $manifestStep + $manifest.ReferenceVm.Name | Should -Be 'sov-ref-vm' + Should -Invoke Sync-LocalBoxReferenceVm -Times 1 -ParameterFilter { + $Settings.IncludeReferenceVm -and $Location -eq 'australiaeast' -and $CustomLocationId -eq 'custom' -and + $ImageId -eq 'image' -and $NetworkId -eq 'vm-network' -and $StorageId -eq 'storage' + } + } + It 'preserves reference inventory and its original observation time when explicitly opted out' { + $Settings.IncludeReferenceVm = $false + @{ SubscriptionId = 'test'; ResourceGroupName = 'shared'; ReferenceVm = @{ Name = 'sov-ref-vm'; Readiness = @{ ObservedAt = 'original' } } } | + ConvertTo-Json -Depth 5 | Set-Content $Settings.ManifestPath + . $manifestStep + $manifest.ReferenceVm.Name | Should -Be 'sov-ref-vm' + $manifest.ReferenceVm.Readiness.ObservedAt | Should -Be 'original' + } + It 'does not import reference inventory from a different scope' { + @{ SubscriptionId = 'other'; ResourceGroupName = 'shared'; ReferenceVm = @{ Name = 'sov-ref-vm' } } | + ConvertTo-Json -Depth 5 | Set-Content $Settings.ManifestPath + . $manifestStep + $manifest.ContainsKey('ReferenceVm') | Should -BeFalse + } + It 'leaves a normal manifest unchanged when there is no reference VM' { + . $manifestStep + $manifest.ContainsKey('ReferenceVm') | Should -BeFalse + } +} + +Describe 'Reference VM live health assertions' { + BeforeAll { + $health = [Management.Automation.Language.Parser]::ParseFile("$PSScriptRoot/localbox.health.tests.ps1", [ref]$null, [ref]$null) + $referenceCheck = $health.Find({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'It' -and + $node.CommandElements[1].Value -like 'is running and Arc-connected*' + }, $true).CommandElements[-1].ScriptBlock.GetScriptBlock() + } + BeforeEach { + $LocalBox = @{ ReferenceVm = @{ MachineId = 'reference' } } + $TimeoutSeconds = 1 + $status = @{ PowerState = 'Running'; ArcStatus = 'Connected'; MdeProvisioningState = 'Succeeded'; PeriodicAssessmentMode = 'AutomaticByPlatform' } + Mock Get-LocalBoxReferenceVmReadiness { $status } + Mock Wait-SovereignCheck { & $Check } + } + It 'passes with connected guest management and successful MDE installation' { + { & $referenceCheck } | Should -Not -Throw + } + It 'rejects incomplete live reference readiness' -TestCases @( + @{ Field = 'PowerState'; Value = 'Off' } + @{ Field = 'ArcStatus'; Value = 'NotReported' } + @{ Field = 'ArcStatus'; Value = 'Disconnected' } + @{ Field = 'MdeProvisioningState'; Value = 'Pending' } + @{ Field = 'MdeProvisioningState'; Value = 'Failed' } + @{ Field = 'PeriodicAssessmentMode'; Value = 'ImageDefault' } + ) { + param($Field, $Value) + $status[$Field] = $Value + { & $referenceCheck } | Should -Throw + } +} + Describe 'Health runner defaults and feedback' { BeforeAll { function kubectl {} @@ -1400,6 +1715,254 @@ Describe 'Resource reconciliation' { } } +Describe 'LocalBox Edge Marketplace image versions' { + BeforeEach { + $clusterId = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/clusters/localboxcluster' + $identifier = @{ publisher = 'microsoftwindowsserver'; offer = 'windowsserver'; sku = '2025-datacenter-azure-edition-smalldisk' } + $sku = @{ marketplaceSkuId = $identifier.sku; marketplaceSkuVersions = @( + @{ name = '26100.33222.260810' }, @{ name = '26100.33438.260905' } + ) } + $catalog = @{ properties = @{ marketplaceSkus = @($sku) } } + Mock Invoke-LocalBoxAz { $catalog } + } + It 'resolves latest from the cluster catalog rather than the newer Azure Compute image' { + Resolve-LocalBoxImageVersion $clusterId $identifier | Should -Be '26100.33438.260905' + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { + $Arguments[0] -eq 'rest' -and $Arguments -contains 'get' -and + $Arguments -contains "https://management.azure.com$clusterId/providers/Microsoft.EdgeMarketplace/offers/microsoftwindowsserver%3Awindowsserver?api-version=2023-08-01" + } + } + It 'sorts versions numerically rather than lexically or by catalog order' { + $sku.marketplaceSkuVersions = @(@{ name = '26100.999.260905' }, @{ name = '26100.1000.260905' }) + Resolve-LocalBoxImageVersion $clusterId $identifier | Should -Be '26100.1000.260905' + } + It 'honors an explicit catalog-listed version' { + Resolve-LocalBoxImageVersion $clusterId $identifier '26100.33222.260810' | Should -Be '26100.33222.260810' + } + It 'rejects a version available in Azure Compute but absent from Edge Marketplace' { + { Resolve-LocalBoxImageVersion $clusterId $identifier '26100.33650.261013' } | Should -Throw '*not advertised*' + } + It 'supports deserialized ArrayList catalog collections' { + $catalog.properties.marketplaceSkus = [Collections.ArrayList]::new(@($sku)) + $sku.marketplaceSkuVersions = [Collections.ArrayList]::new(@(@{ name = '26100.33438.260905' })) + Resolve-LocalBoxImageVersion $clusterId $identifier | Should -Be '26100.33438.260905' + } + It 'fails explicitly for unusable catalogs' -TestCases @( + @{ Invalid = 'missing catalog' }; @{ Invalid = 'missing SKU' }; @{ Invalid = 'duplicate SKU' } + @{ Invalid = 'empty versions' }; @{ Invalid = 'invalid version' } + ) { + param($Invalid) + switch ($Invalid) { + 'missing catalog' { $catalog.properties.Remove('marketplaceSkus') } + 'missing SKU' { $sku.marketplaceSkuId = 'different' } + 'duplicate SKU' { $catalog.properties.marketplaceSkus = @($sku, $sku) } + 'empty versions' { $sku.marketplaceSkuVersions = @() } + 'invalid version' { $sku.marketplaceSkuVersions = @(@{ name = 'latest' }) } + } + { Resolve-LocalBoxImageVersion $clusterId $identifier } | Should -Throw '*Edge Marketplace*' + } + It 'propagates catalog authorization failures without a fallback version' { + Mock Invoke-LocalBoxAz { throw 'AuthorizationFailed' } + { Resolve-LocalBoxImageVersion $clusterId $identifier } | Should -Throw '*AuthorizationFailed*' + } + It 'rejects a wrong resource scope before querying the catalog' { + { Resolve-LocalBoxImageVersion '/subscriptions/test/resourceGroups/localbox' $identifier } | Should -Throw '*cluster resource ID*' + Should -Invoke Invoke-LocalBoxAz -Times 0 + } +} + +Describe 'LocalBox failed image recovery' { + BeforeEach { + $imageId = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/marketplaceGalleryImages/windows' + $expected = @{ location = 'australiaeast'; extendedLocation = @{ name = 'custom' } + properties = @{ containerId = 'storage'; osType = 'Windows' + identifier = @{ publisher = 'microsoftwindowsserver'; offer = 'windowsserver'; sku = '2025-datacenter-azure-edition-smalldisk' } } } + $actual = $expected | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable + $actual.properties.provisioningState = 'Failed' + $actual.properties.version = @{ name = '26100.33650.261013' } + $script:imagePresent = $true + $createArguments = @('stack-hci-vm', 'image', 'create', '--name', 'windows') + $syncArguments = @{ + Id = $imageId; Expected = $expected; CreateArguments = $createArguments + ClusterId = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/clusters/localboxcluster' + } + Mock Invoke-LocalBoxAz { + if ($Arguments[1] -eq 'list' -and $script:imagePresent) { @(@{ id = $imageId }) } + elseif ($Arguments[1] -eq 'delete') { $script:imagePresent = $false } + } + Mock Get-LocalBoxResource { $actual } + Mock Wait-LocalBoxResource { + $imported = $expected | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable + $imported.properties.version = @{ name = '26100.33438.260905' } + $imported + } + Mock Resolve-LocalBoxImageVersion { '26100.33438.260905' } + Mock Start-Sleep {} + } + It 'removes only the matching Failed image, verifies absence and imports once' { + Sync-LocalBoxImage @syncArguments -Confirm:$false | Out-Null + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { + $Arguments[1] -eq 'delete' -and $Arguments -contains $imageId -and $Arguments -contains '2024-01-01' + } + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { + $Arguments[2] -eq 'create' -and $Arguments[$Arguments.IndexOf('--version') + 1] -eq '26100.33438.260905' + } + $expected.properties.Contains('version') | Should -BeFalse + Should -Invoke Invoke-LocalBoxAz -Times 3 -Exactly -ParameterFilter { + $Arguments[1] -eq 'list' -and $Arguments -contains 'test' -and $Arguments -contains 'localbox' + } + } + It 'never deletes healthy or in-progress images' -TestCases @( + @{ State = 'Succeeded' }; @{ State = 'Creating' } + ) { + param($State) + $actual.properties.provisioningState = $State + Sync-LocalBoxImage @syncArguments -Confirm:$false | Out-Null + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'create' } + Should -Invoke Wait-LocalBoxResource -Times 1 -Exactly + Should -Invoke Resolve-LocalBoxImageVersion -Times 0 + } + It 'does not remove conflicting Failed images' -TestCases @( + @{ Field = 'location' }; @{ Field = 'storage' }; @{ Field = 'custom' }; @{ Field = 'sku' }; @{ Field = 'missing' } + ) { + param($Field) + switch ($Field) { + 'location' { $actual.location = 'westeurope' } + 'storage' { $actual.properties.containerId = 'different' } + 'custom' { $actual.extendedLocation.name = 'different' } + 'sku' { $actual.properties.identifier.sku = 'different' } + 'missing' { $actual.properties.Remove('identifier') } + } + { Sync-LocalBoxImage @syncArguments -Confirm:$false } | Should -Throw + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'create' } + } + It 'does not delete or import a Failed image during WhatIf' { + Sync-LocalBoxImage @syncArguments -WhatIf | Should -BeNullOrEmpty + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'create' } + Should -Invoke Wait-LocalBoxResource -Times 0 + } + It 'waits for a previously started deletion without submitting another delete' { + $actual.properties.provisioningState = 'Deleting' + $script:imageLists = 0 + Mock Invoke-LocalBoxAz { + if ($Arguments[1] -eq 'list') { + $script:imageLists++ + if ($script:imageLists -le 2) { @(@{ id = $imageId }) } + } + } + Sync-LocalBoxImage @syncArguments -Confirm:$false | Out-Null + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' } + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { $Arguments[2] -eq 'create' } + Should -Invoke Start-Sleep -Times 1 -Exactly + } + It 'does not wait for deletion during WhatIf' { + $actual.properties.provisioningState = 'Deleting' + Sync-LocalBoxImage @syncArguments -WhatIf | Should -BeNullOrEmpty + Should -Invoke Start-Sleep -Times 0 + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'create' } + } + It 'does not recreate when deletion fails' { + Mock Invoke-LocalBoxAz { throw 'AuthorizationFailed' } -ParameterFilter { $Arguments[1] -eq 'delete' } + { Sync-LocalBoxImage @syncArguments -Confirm:$false } | Should -Throw '*AuthorizationFailed*' + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[2] -eq 'create' } + } + It 'bounds deletion waits and does not import over a remaining object' { + Mock Invoke-LocalBoxAz {} -ParameterFilter { $Arguments[1] -eq 'delete' } + { Sync-LocalBoxImage @syncArguments -TimeoutSeconds 0 -Confirm:$false } | Should -Throw '*Timed out waiting for image deletion*' + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments[2] -eq 'create' } + } + It 'surfaces a fresh import failure without deleting it or looping on retries' { + $script:imagePresent = $false + Mock Invoke-LocalBoxAz { throw 'GenerateTokenFromEdgeMarketplaceServiceFailed: NotFound' } -ParameterFilter { $Arguments[2] -eq 'create' } + { Sync-LocalBoxImage @syncArguments -Confirm:$false } | Should -Throw '*GenerateTokenFromEdgeMarketplaceServiceFailed*' + Should -Invoke Invoke-LocalBoxAz -Times 1 -Exactly -ParameterFilter { $Arguments[2] -eq 'create' } + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' } + } + It 'refuses recovery for a non-image resource ID' { + $syncArguments.Id = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI/logicalNetworks/net' + { Sync-LocalBoxImage @syncArguments } | Should -Throw '*exact Azure Local Marketplace image*' + Should -Invoke Invoke-LocalBoxAz -Times 0 + } + It 'preserves the failed image when catalog discovery fails' { + Mock Resolve-LocalBoxImageVersion { throw 'Catalog unavailable' } + { Sync-LocalBoxImage @syncArguments } | Should -Throw '*Catalog unavailable*' + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'create' } + } + It 'passes an explicit requested version through catalog validation for a new image' { + $script:imagePresent = $false + $expected.properties.version = @{ name = '26100.33438.260905' } + Sync-LocalBoxImage @syncArguments | Out-Null + Should -Invoke Resolve-LocalBoxImageVersion -Times 1 -Exactly -ParameterFilter { + $RequestedVersion -eq '26100.33438.260905' -and $ClusterId -eq $syncArguments.ClusterId + } + } + It 'resolves a new image during WhatIf without importing it' { + $script:imagePresent = $false + Sync-LocalBoxImage @syncArguments -WhatIf | Should -BeNullOrEmpty + Should -Invoke Resolve-LocalBoxImageVersion -Times 1 -Exactly + Should -Invoke Invoke-LocalBoxAz -Times 0 -ParameterFilter { $Arguments -contains 'delete' -or $Arguments -contains 'create' } + Should -Invoke Wait-LocalBoxResource -Times 0 + } + It 'verifies the imported version rather than accepting a different resolved image' { + Mock Wait-LocalBoxResource { $actual } + { Sync-LocalBoxImage @syncArguments -Confirm:$false } | Should -Throw '*Conflicting*version.name*' + } +} + +Describe 'LocalBox preparation dependency order' { + BeforeAll { + $source = ${function:Invoke-LocalBoxPreparation}.ToString() + $start = $source.IndexOf('$location = $custom.location') + $end = $source.IndexOf('$manifest = @{', $start) + $prepareWorkloads = [scriptblock]::Create("[CmdletBinding(SupportsShouldProcess)]param()`n" + $source.Substring($start, $end - $start)) + } + BeforeEach { + $events = [Collections.Generic.List[string]]::new() + $scope = '/subscriptions/test/resourceGroups/localbox' + $Settings = @{ SubscriptionId = 'test'; ResourceGroupName = 'localbox'; TimeoutMinutes = 1; ImageName = 'windows'; ImageVersion = 'latest' + AksClusterName = 'localbox-aks'; NodeCount = 3; NodeVmSize = 'node'; ControlPlaneVmSize = 'control'; SkipAks = $false; IncludeReferenceVm = $true + KubernetesRuntimeObjectId = '11111111-1111-1111-1111-111111111111' } + $custom = @{ id = 'custom'; location = 'australiaeast' } + $cluster = @(@{ id = "$scope/providers/Microsoft.AzureStackHCI/clusters/localboxcluster" }) + $config = @{ AKSControlPlaneIP = '10.10.0.5' } + $storageId = 'storage' + $imageId = 'image' + $groupId = 'group' + $metalLb = @{} + Mock Sync-LocalBoxResource { $events.Add("network:$($Id.Split('/')[-1])") } + Mock Invoke-LocalBoxAz { + if ($Arguments[0] -eq 'aksarc') { + $events.Add($(if ($Arguments -contains '--validate') { 'aks-validate' } else { 'aks-create' })) + } + elseif ($Arguments[0] -eq 'provider') { @{ registrationState = 'Registered' } } + } + Mock Wait-LocalBoxResource { @{ properties = @{ connectivityStatus = 'Connected' } } } + Mock Assert-LocalBoxProperties {} + Mock Sync-LocalBoxAksProxyRole { $events.Add('proxy') } + Mock Sync-LocalBoxMetalLb { $events.Add('metallb') } + Mock Sync-LocalBoxImage { $events.Add('image') } + Mock Sync-LocalBoxReferenceVm { $events.Add('reference') } + } + It 'completes networks, AKS and MetalLB before importing the image and reference VM' { + & $prepareWorkloads -Confirm:$false + $events -join ',' | Should -Be 'network:localbox-vm-lnet-vlan200,network:localbox-aks-lnet-vlan110,aks-validate,aks-create,proxy,metallb,image,reference' + Should -Invoke Sync-LocalBoxImage -Times 1 -Exactly -ParameterFilter { + $ClusterId -eq "$scope/providers/Microsoft.AzureStackHCI/clusters/localboxcluster" -and $CreateArguments -notcontains '--version' + } + } + It 'preserves completed infrastructure when the late image stage fails and does not start the reference VM' { + Mock Sync-LocalBoxImage { $events.Add('image-failed'); throw 'Import failed' } + { & $prepareWorkloads -Confirm:$false } | Should -Throw '*Import failed*' + $events -join ',' | Should -Be 'network:localbox-vm-lnet-vlan200,network:localbox-aks-lnet-vlan110,aks-validate,aks-create,proxy,metallb,image-failed' + Should -Invoke Sync-LocalBoxReferenceVm -Times 0 + } + It 'preserves the explicit SkipAks behavior while keeping the VM stages last' { + $Settings.SkipAks = $true + & $prepareWorkloads -Confirm:$false + $events -join ',' | Should -Be 'network:localbox-vm-lnet-vlan200,network:localbox-aks-lnet-vlan110,image,reference' + } +} + Describe 'LocalBox image storage conflicts' { BeforeEach { $scope = '/subscriptions/test/resourceGroups/localbox/providers/Microsoft.AzureStackHCI' @@ -1878,6 +2441,7 @@ Describe 'Storage execution safety' { Mock New-VHD {} Mock Add-VMHardDiskDrive {} Mock Invoke-Command {} + Mock Get-LocalBoxPartitionState { @{ Size = 1TB - 2MB; NeedsExpansion = $false } } 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' { @@ -1893,7 +2457,77 @@ Describe 'Storage execution safety' { 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 + } + It 'resumes partition expansion after an interrupted virtual-disk resize, then skips it on the next run' { + Mock Test-Path { $true } + Mock Get-VMHardDiskDrive { @(@{ Path = Join-Path $TestDrive "$VMName-microhack-s2d.vhdx" }) } + Mock Get-LocalBoxNodeState { @{ PoolHealth = 'Healthy'; DiskHealth = 'Healthy'; VolumeHealth = 'Healthy'; NodesUp = $true; StorageJobs = 0; UnhealthyPhysicalDisks = 0; Size = 1TB } } + $script:partitionReads = 0 + Mock Get-LocalBoxPartitionState { + $script:partitionReads++ + @{ Size = 679GB; NeedsExpansion = $script:partitionReads -eq 1 } + } + Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -Confirm:$false + 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' } + Should -Invoke Invoke-Command -Times 1 -Exactly -ParameterFilter { $ScriptBlock.ToString() -match 'Resize-Partition' } + } + It 'reports an incomplete partition expansion instead of claiming completion' { + Mock Get-LocalBoxPartitionState { @{ Size = 679GB; NeedsExpansion = $true } } + { Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -Confirm:$false } | + Should -Throw '*still has unexpanded partition space*' + } + It 'does not expand an unfinished partition during WhatIf' { + Mock Get-LocalBoxPartitionState { @{ Size = 679GB; NeedsExpansion = $true } } + Initialize-LocalBoxStorage -Configuration $configuration -Credential $credential -DesiredSizeGB 1024 -WhatIf + Should -Invoke Invoke-Command -Times 0 + } +} + +Describe 'LocalBox partition capacity detection' { + BeforeAll { + function Get-Volume { param($FileSystemLabel) } + function Get-Partition { param([Parameter(ValueFromPipeline)]$InputObject) } + function Get-Disk { param([Parameter(ValueFromPipeline)]$InputObject) } + function Get-PartitionSupportedSize { throw 'Unexpected expensive supported-size query' } + } + BeforeEach { + $partition = @{ Size = 1TB - 2MB; Offset = 1MB } + $credential = [pscredential]::new('test-user', [Security.SecureString]::new()) + Mock Invoke-Command { & $ScriptBlock } + Mock Get-Volume { @{ FileSystemLabel = 'UserStorage_1' } } + Mock Get-Partition { $partition } + Mock Get-Disk { @{ Size = 1TB } } + Mock Get-PartitionSupportedSize { throw 'Unexpected expensive supported-size query' } + } + It 'ignores an alignment/GPT tail of at most 1 MiB' -TestCases @( + @{ Tail = 0 }; @{ Tail = 512KB }; @{ Tail = 1MB } + ) { + param($Tail) + $partition.Size = 1TB - 1MB - $Tail + (Get-LocalBoxPartitionState 'node' $credential).NeedsExpansion | Should -BeFalse + Should -Invoke Get-PartitionSupportedSize -Times 0 + } + It 'detects actual unfinished expansion above the alignment allowance' -TestCases @( + @{ Tail = 1MB + 1 }; @{ Tail = 345GB } + ) { + param($Tail) + $partition.Size = 1TB - 1MB - $Tail + (Get-LocalBoxPartitionState 'node' $credential).NeedsExpansion | Should -BeTrue + Should -Invoke Get-PartitionSupportedSize -Times 0 + } + It 'rejects ambiguous partitions and missing remoting output' { + Mock Get-Partition { @($partition, $partition) } + { Get-LocalBoxPartitionState 'node' $credential } | Should -Throw '*uniquely resolve*' + Mock Invoke-Command {} + { Get-LocalBoxPartitionState 'node' $credential } | Should -Throw '*No valid*' + } + It 'rejects invalid disk geometry rather than silently skipping work' { + $partition.Size = 2TB + { Get-LocalBoxPartitionState 'node' $credential } | Should -Throw '*Invalid*' } } 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 3163ce4cb..22970471b 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 @@ -43,6 +43,50 @@ exception while retaining the four European regions. These checks make no Azure changes; a fresh Console deployment and participant VM creation remain required to validate regional service availability and effective inherited policies. +## Reference-VM checks + +Run `Invoke-Pester ./prepare-localbox.tests.ps1 -Output Detailed` for the offline +preparation and capacity regression suite. Reference-VM tests cover default inclusion, explicit opt-out and WhatIf, +the 15-character Windows name limit, separate credentials, resource reuse and +conflicts, periodic assessment configuration, and pending/failed MDE status. +The shared VM submission tests check secure parameter-file transport and cleanup, +error redaction, and an ARM preview restricted to three new VM resources. + +When the preparation manifest contains `ReferenceVm`, the live LocalBox health +suite additionally checks the expected machine/NIC/instance, running state, Arc +connection, MDE installation and periodic assessment mode. Older manifests without +that optional entry retain the normal test set. These checks do **not** verify +ongoing Defender device reporting or completed Defender/Update Manager results; +perform the [organizer reference readiness checks](../localbox/readme.md#reference-readiness-and-participant-use) +before the workshop. The new reference-VM path still requires a live Windows/LocalBox +integration test; offline mocks are not deployment evidence. + +## Preparation recovery and ordering checks + +Default/preflight tests verify that no manual address-reservation switch is +required for the standard greenfield topology, while configured gateway, +infrastructure-address, AKS/VIP and VLAN conflicts still stop preparation. + +The same offline suite checks the standard nonprompting confirmation impact, +continued support for explicit `-Confirm`/`-WhatIf`, and safe recovery of matching +Failed Marketplace images, conflicting-image protection, bounded deletion waits, +and a single import attempt per run. Image-version tests cover numeric selection +from the cluster-scoped Edge Marketplace catalog, explicit version validation, +malformed/unavailable catalogs, discovery before deletion, unchanged reuse of +working images, and verification of the actual imported version. The resolver was +also run read-only against the live cluster catalog on 2026-10-21; it selected +`26100.33438.260905`, while Azure Compute exposed the newer `26100.33650.261013`. +It executes the preparation workload sequence +with mocked Azure calls to verify that networks, AKS and MetalLB precede image +import and the optional reference VM, including when image import fails. + +Storage tests verify no repeated disk/partition mutations on a completed rerun, +resumption of an interrupted partition expansion, and detection of unallocated +space without calling `Get-PartitionSupportedSize` on an already expanded +partition. The alignment/GPT allowance is tested at and above 1 MiB. Live +LocalBox validation is still required for actual Windows storage behavior and +Marketplace failure recovery. + ## Hosted Defender unit tests Run `Invoke-Pester ./hosted-defender.tests.ps1 -Output Detailed` for the focused diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/defender-recommendations-populated.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/defender-recommendations-populated.png new file mode 100644 index 000000000..d03a3f8a3 Binary files /dev/null and b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/defender-recommendations-populated.png differ diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md index 7bf152eac..80f976f3b 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 @@ -217,10 +217,24 @@ Select the subscription and review **Defender plans**, including the **Servers** The example subscription uses **Servers Plan 2** with **Full** monitoring coverage. Your lab's approved plan may differ. Ask the facilitator to confirm **Settings & monitoring > Endpoint protection** is **On**, even if the Servers plan is enabled. These subscription-level settings are context, not evidence that your individual VM has completed onboarding. > [!NOTE] -> A newly created VM can take time to appear and complete onboarding. If it is missing, first recheck **Guest management: Enabled (Connected)** and the selected subscription/resource group, then refresh Inventory. Ask the facilitator to check plan coverage and extension onboarding if it remains missing or unprotected. An Inventory entry alone does not prove that Defender for Servers protection is active. Do not enable a paid subscription-level plan yourself in a hosted lab. +> Automatic MDE onboarding is asynchronous. [Microsoft documents up to one hour for new machines](https://learn.microsoft.com/azure/defender-for-cloud/enable-defender-for-endpoint#enable-defender-for-endpoint-integration-on-a-subscription) after integration is established; first-time integration in a tenant can take up to 12 hours. Keep your VM powered on with **Guest management: Enabled (Connected)**. Continue with Tasks 4 and 5 while waiting; do not toggle Defender settings or install the extension manually to hurry it along. + +Open **your VM -> Extensions** and look for **MDE.Windows**, then check its provisioning +status is **Succeeded**. Record the extension/onboarding status separately from the +Servers plan status. Successful extension installation is not proof of ongoing device +reporting; ask the facilitator to verify the device in the Microsoft Defender portal +if you do not have access. If the extension is absent after the expected onboarding +window, or reports a failure, ask the facilitator to check guest connectivity and +Endpoint protection. Do not change subscription-level settings yourself. ### 3.2 Review Your VM's Security Posture +If your facilitator has prepared **`sov-ref-vm`** in the shared LocalBox resource +group, you may inspect its existing recommendations while your own VM is still +onboarding. Record the reference VM's resource ID separately. **Inspect only: do +not remediate, change extensions, restart or delete it.** Return to your own VM +for the exercise; the reference does not replace creating and assessing your VM. + 1. From your VM's resource details in Defender for Cloud, open its **Recommendations**. Alternatively, open your Azure Local VM resource and select **Settings -> Security** to view its recommendations 2. Review the assessment status and any recommendations for that VM. If navigating from the subscription-wide **Recommendations** view, verify the affected resource is your VM by resource ID 3. For an available recommendation, inspect its severity, reason, and remediation guidance @@ -229,6 +243,17 @@ The example subscription uses **Servers Plan 2** with **Full** monitoring covera The example VM shows **Microsoft Defender for Servers On** and **No recommendations to display**. Use **View all recommendations in Defender for Cloud** to investigate further, keeping the results scoped to your own VM. +The following screenshot shows recommendations after assessments had time to populate: + +Azure Arc VM win01 Security page with Defender for Servers On, populated update and Windows configuration recommendations, and zero security alerts. + +In this workshop test, the view was empty shortly after MDE installation and +contained recommendations when checked the next morning, about **10 hours after +the MDE extension was created**. They may have appeared earlier between checks; +this is an observed example, not a required wait or guaranteed processing time. +Your findings and counts will differ. Continue the workshop rather than waiting +for recommendations to appear. + Depending on the enabled plan and completed assessments, recommendations may concern: | Area | What to investigate | @@ -236,11 +261,24 @@ Depending on the enabled plan and completed assessments, recommendations may con | System updates | Missing OS security updates | | Endpoint protection | Defender for Endpoint onboarding or protection status | | Vulnerabilities | Detected software vulnerabilities and remediation guidance | +| OS configuration | Windows security settings assessed against the configured baseline | Only investigate or remediate your own VM. Do not apply subscription-wide fixes or change shared LocalBox infrastructure as part of this exercise. > [!NOTE] -> Recommendations may still be pending on a fresh VM, and an assessed VM may have no active recommendations. Record the displayed assessment status separately from the verified protection status. An empty recommendations list does not demonstrate completed assessment or compliance. You can complete Task 4 while waiting, then return to review your VM's results. +> **Zero recommendations is a valid observation, not proof that assessment is complete.** MDE installation, telemetry collection, security assessments, and display of recommendations happen separately. Recommendations can appear later than the extension, and different checks have different evaluation schedules; the one-hour onboarding window is not a deadline for recommendations. A machine with completed assessments can also have no applicable findings. + +Use **View all recommendations in Defender for Cloud**, keep the scope on your VM, +and review any available assessment status and timestamp. Record one of these outcomes: + +- Findings available: explain one recommendation and its proposed remediation. +- Assessments completed with no findings: record that outcome. +- No assessment evidence yet: record **assessment pending/not yet confirmed**, not "fully compliant." + +You do not need a nonzero recommendation count to complete this exercise. Continue +with the remaining tasks and revisit later if time permits; do not weaken security +to generate findings. [Reviewing recommendations](https://learn.microsoft.com/azure/defender-for-cloud/review-security-recommendations) +provides more detail on the available views and filters. ### 3.3 Review Security Alerts (if any) @@ -265,6 +303,10 @@ No alerts is an expected outcome for a new lab VM; do not generate an alert or s **Use Azure Update Manager to assess OS updates on the same Windows Server VM you created in Task 2.** +You may also view existing assessment results and timestamps on `sov-ref-vm`, if +provided by the facilitator. **Do not select the shared reference VM for Check +for updates, patch installation, scheduling or any configuration change.** + ### 4.1 Locate Your VM 1. In the Azure Portal, search for **Azure Update Manager** @@ -554,7 +596,7 @@ Before completing this challenge, verify: - [ ] You can navigate and understand the LocalBox hybrid environment in the Azure Portal - [ ] You have deployed your own VM on Azure Local and verified that guest management is Enabled (Connected) -- [ ] You have verified Defender for Servers coverage for your VM and reviewed its available recommendations, or recorded that its assessment is still pending +- [ ] You have verified Defender for Servers plan coverage, recorded MDE extension/onboarding status separately, and reviewed available recommendations or recorded that assessment is pending/not yet confirmed; a nonzero recommendation count is not required - [ ] You have completed an Azure Update Manager assessment for your VM and reviewed its timestamp and results, including when no updates are pending - [ ] You verified the organizer-prepared `arcnetworking` extension is Succeeded, MetalLB workloads are healthy, and `aks-pool` has the reserved ARP VIP range - [ ] Your application rolled out successfully in your team's namespace and received an IP from that pool