|
| 1 | + |
| 2 | +<# |
| 3 | +.SYNOPSIS |
| 4 | + Downloads a Windows Server 2025 Datacenter VHD from Azure Marketplace and imports it |
| 5 | + into Azure Stack Hub as a platform image. |
| 6 | +
|
| 7 | +.DESCRIPTION |
| 8 | + DISCLAIMER: This script is provided as an EXAMPLE ONLY and is NOT a supported service |
| 9 | + offering. It is provided under the MIT License on an "AS-IS" basis, without warranty |
| 10 | + of any kind, express or implied. Use at your own risk. |
| 11 | +
|
| 12 | + This script performs the following steps: |
| 13 | + 1. Installs the required PowerShell modules (Az, AzureStack). |
| 14 | + 2. Logs into public Azure via Azure CLI and finds the latest WS2025 Gen1 image. |
| 15 | + 3. Creates a managed disk from that marketplace image, then exports the VHD using AzCopy. |
| 16 | + 4. Connects to Azure Stack Hub, uploads the VHD to a storage account. |
| 17 | + 5. Registers the VHD as a platform image in the Azure Stack Hub marketplace. |
| 18 | +
|
| 19 | +.NOTES |
| 20 | + Prerequisites: |
| 21 | + - Azure CLI installed (see _Pre-req_Install_AzCLI.ps1) |
| 22 | + - Run as Administrator |
| 23 | + - Azure Stack Hub admin access |
| 24 | + - Sufficient disk space for the VHD download (~30 GB for full disk, ~10 GB for small disk) |
| 25 | +
|
| 26 | + Adjust the parameters below to match your environment before running. |
| 27 | +
|
| 28 | +.PARAMETER CleanAzModules |
| 29 | + If specified, uninstalls every existing Az.*, Azs.* and Azure* PowerShell module on the |
| 30 | + machine before installing the 2020-09-01-hybrid profile. WARNING: this affects every |
| 31 | + PowerShell session on this workstation, not just this script. Off by default. |
| 32 | +
|
| 33 | +.PARAMETER ClearAzCliAccount |
| 34 | + If specified, runs 'az account clear' which removes ALL cached Azure CLI sessions on |
| 35 | + this machine before logging in. Off by default. |
| 36 | +
|
| 37 | +.PARAMETER AzureLocation |
| 38 | + Azure (public cloud) region used to create the temporary managed disk. Default: eastus. |
| 39 | +#> |
| 40 | +[CmdletBinding()] |
| 41 | +param( |
| 42 | + [switch]$CleanAzModules, |
| 43 | + [switch]$ClearAzCliAccount, |
| 44 | + [string]$AzureLocation = 'eastus' |
| 45 | +) |
| 46 | + |
| 47 | +#region ========================== PARAMETERS - EDIT THESE ========================== |
| 48 | + |
| 49 | +# --- Azure (public cloud) settings --- |
| 50 | +$AzureResourceGroup = "ws2025-image-rg" # Resource group in Azure to create the temp disk |
| 51 | +$AzureDiskName = "WindowsServer-2025" # Name for the temporary managed disk in Azure |
| 52 | + |
| 53 | +# --- Local download settings --- |
| 54 | +$VhdDownloadPath = "C:\VHDs\WS2025-datacenter.vhd" # Local path to save the downloaded VHD |
| 55 | + |
| 56 | +# --- Azure Stack Hub settings --- |
| 57 | +$EnvironmentName = "AzureStackEnv" # Name for the Az environment registration |
| 58 | +$AdminArmEndpoint = "https://adminmanagement.<region>.<fqdn>" # Azure Stack Hub admin ARM endpoint |
| 59 | +$TenantID = "<your-aad-tenant-id>" # Azure AD tenant ID for your Azure Stack Hub |
| 60 | +$HubLocation = "<your-hub-region>" # Azure Stack Hub region (e.g., "local", "redmond") |
| 61 | +$StorageEndpointDnsSuffix = "<region>.<fqdn>" # External domain suffix (e.g., "local.azurestack.external") |
| 62 | +$KeyVaultDnsSuffix = "vault.$StorageEndpointDnsSuffix" |
| 63 | + |
| 64 | +# Azure Stack Hub credentials (service admin) |
| 65 | +$ServiceAdminUserName = "<service-admin-upn>" # e.g., admin@contoso.onmicrosoft.com |
| 66 | +# Prompt for the password interactively (do NOT hard-code passwords) |
| 67 | +$ServiceAdminCredential = Get-Credential -UserName $ServiceAdminUserName -Message "Enter Azure Stack Hub Service Admin password" |
| 68 | + |
| 69 | +# Azure Stack Hub storage settings |
| 70 | +$HubResourceGroup = "ws2025-image-rg" # Resource group on Azure Stack Hub for the storage account |
| 71 | +$HubStorageAccountName = "ws2025vhds" # Storage account name on Azure Stack Hub (3-24 lowercase alphanumeric) |
| 72 | + |
| 73 | +#endregion ========================================================================== |
| 74 | + |
| 75 | +# ================================================================================== |
| 76 | +# Step 1: Install required PowerShell modules |
| 77 | +# ================================================================================== |
| 78 | +Write-Host "`n[Step 1] Installing required PowerShell modules..." -ForegroundColor Cyan |
| 79 | + |
| 80 | +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 |
| 81 | + |
| 82 | +Install-Module PowershellGet -MinimumVersion 2.2.3 -Repository PSGallery -Force -ErrorAction SilentlyContinue |
| 83 | + |
| 84 | +if ($CleanAzModules) { |
| 85 | + Write-Host " -CleanAzModules specified: uninstalling existing Az / Azs / Azure modules (this affects ALL PowerShell sessions on this machine)..." -ForegroundColor Yellow |
| 86 | + Get-Module -Name Azure* -ListAvailable | Uninstall-Module -Force -Verbose -ErrorAction Continue |
| 87 | + Get-Module -Name Azs.* -ListAvailable | Uninstall-Module -Force -Verbose -ErrorAction Continue |
| 88 | + Get-Module -Name Az.* -ListAvailable | Uninstall-Module -Force -Verbose -ErrorAction Continue |
| 89 | +} else { |
| 90 | + Write-Host " Skipping module cleanup (re-run with -CleanAzModules if you need a clean reinstall)." -ForegroundColor DarkGray |
| 91 | +} |
| 92 | + |
| 93 | +Install-Module -Name Az.BootStrapper -Repository PSGallery -Force |
| 94 | +Install-AzProfile -Profile 2020-09-01-hybrid -Force |
| 95 | +Install-Module -Name AzureStack -RequiredVersion 2.4.0 |
| 96 | + |
| 97 | +Get-Module -Name "Az*" -ListAvailable |
| 98 | +Get-Module -Name "Azs*" -ListAvailable |
| 99 | + |
| 100 | +Import-Module -Name Az -Force -DisableNameChecking | Out-Null |
| 101 | + |
| 102 | +# ================================================================================== |
| 103 | +# Step 2: Log into public Azure and find the latest WS2025 Gen1 image |
| 104 | +# ================================================================================== |
| 105 | +Write-Host "`n[Step 2] Logging into public Azure and finding WS2025 image..." -ForegroundColor Cyan |
| 106 | + |
| 107 | +if ($ClearAzCliAccount) { |
| 108 | + Write-Host " -ClearAzCliAccount specified: clearing all cached Azure CLI sessions on this machine..." -ForegroundColor Yellow |
| 109 | + az account clear |
| 110 | +} |
| 111 | +az login --use-device-code |
| 112 | +az account show |
| 113 | + |
| 114 | +# Find all Windows Server 2025 images (Gen1 only, no Upgrade SKUs) |
| 115 | +Write-Host "Querying Azure Marketplace for Windows Server 2025 images (this can take up to a minute)..." -ForegroundColor Yellow |
| 116 | +$images = az vm image list --all ` |
| 117 | + --publisher MicrosoftWindowsServer ` |
| 118 | + --offer WindowsServer ` |
| 119 | + --output json | ConvertFrom-Json |
| 120 | + |
| 121 | +$filteredImages = $images | Where-Object { |
| 122 | + $_.sku -like "*2025*" -and $_.sku -inotlike "*Upgrade*" |
| 123 | +} |
| 124 | + |
| 125 | +$gen1Images = $filteredImages | Where-Object { |
| 126 | + $_.sku -notlike "*-g2" -and |
| 127 | + $_.sku -notlike "*-gen2" -and |
| 128 | + $_.sku -inotlike "*azure-edition*" # Azure Edition SKUs are not supported on Azure Stack Hub |
| 129 | +} |
| 130 | + |
| 131 | +# Group by SKU and pick the latest version of each |
| 132 | +$latestBySku = $gen1Images | Group-Object -Property sku | ForEach-Object { |
| 133 | + $_.Group | Sort-Object version -Descending | Select-Object -First 1 |
| 134 | +} | Sort-Object sku |
| 135 | + |
| 136 | +if (-not $latestBySku -or $latestBySku.Count -eq 0) { |
| 137 | + throw "No Windows Server 2025 Gen1 images found in the Azure Marketplace." |
| 138 | +} |
| 139 | + |
| 140 | +# Display available images and prompt the user to choose |
| 141 | +Write-Host "`nAvailable Windows Server 2025 Gen1 images:" -ForegroundColor Yellow |
| 142 | +Write-Host ("-" * 80) -ForegroundColor DarkGray |
| 143 | +for ($i = 0; $i -lt $latestBySku.Count; $i++) { |
| 144 | + $img = $latestBySku[$i] |
| 145 | + Write-Host " [$($i + 1)] $($img.sku) (version $($img.version))" -ForegroundColor White |
| 146 | +} |
| 147 | +Write-Host ("-" * 80) -ForegroundColor DarkGray |
| 148 | + |
| 149 | +do { |
| 150 | + $choice = Read-Host "`nEnter the number of the image to download (1-$($latestBySku.Count))" |
| 151 | + $choiceIndex = $choice -as [int] |
| 152 | +} while ($choiceIndex -lt 1 -or $choiceIndex -gt $latestBySku.Count) |
| 153 | + |
| 154 | +$selectedImage = $latestBySku[$choiceIndex - 1] |
| 155 | +Write-Host "`nSelected image: $($selectedImage.urn)" -ForegroundColor Green |
| 156 | + |
| 157 | +# ================================================================================== |
| 158 | +# Step 3: Create a managed disk from the marketplace image and export VHD |
| 159 | +# ================================================================================== |
| 160 | +Write-Host "`n[Step 3] Creating managed disk and exporting VHD..." -ForegroundColor Cyan |
| 161 | + |
| 162 | +az group create -n $AzureResourceGroup -l $AzureLocation --output none 2>$null |
| 163 | + |
| 164 | +az disk create ` |
| 165 | + -g $AzureResourceGroup ` |
| 166 | + -n $AzureDiskName ` |
| 167 | + --image-reference $selectedImage.urn |
| 168 | + |
| 169 | +$diskAccess = az disk grant-access ` |
| 170 | + --duration-in-seconds 3600 ` |
| 171 | + --access-level Read ` |
| 172 | + --name $AzureDiskName ` |
| 173 | + --resource-group $AzureResourceGroup ` |
| 174 | + -o json | ConvertFrom-Json |
| 175 | + |
| 176 | +# Download AzCopy (recommended over Invoke-WebRequest for large files) |
| 177 | +$azcopyFolder = Join-Path $env:TEMP "azcopy" |
| 178 | +if (Test-Path $azcopyFolder) { |
| 179 | + Remove-Item $azcopyFolder -Recurse -Force |
| 180 | +} |
| 181 | +New-Item -Path $azcopyFolder -ItemType Directory | Out-Null |
| 182 | +$azcopyZipFile = Join-Path $azcopyFolder "AzCopy.zip" |
| 183 | +Invoke-WebRequest -Uri "https://aka.ms/downloadazcopy-v10-windows" -OutFile $azcopyZipFile |
| 184 | +Expand-Archive -Path $azcopyZipFile -DestinationPath $azcopyFolder -Force |
| 185 | +$azCopyExe = (Get-ChildItem -Path $azcopyFolder -Recurse | Where-Object { $_.Name -eq "azcopy.exe" }).FullName |
| 186 | +if (-not $azCopyExe -or -not (Test-Path $azCopyExe)) { |
| 187 | + throw "Failed to download or locate azcopy executable." |
| 188 | +} |
| 189 | + |
| 190 | +# Ensure the download directory exists |
| 191 | +$vhdDir = Split-Path $VhdDownloadPath -Parent |
| 192 | +if (-not (Test-Path $vhdDir)) { New-Item -Path $vhdDir -ItemType Directory -Force | Out-Null } |
| 193 | + |
| 194 | +Write-Host "Downloading VHD to $VhdDownloadPath (this may take a while)..." -ForegroundColor Yellow |
| 195 | +& $azCopyExe cp $diskAccess.accessSas $VhdDownloadPath |
| 196 | + |
| 197 | +# Revoke disk access and clean up Azure resources |
| 198 | +az disk revoke-access -g $AzureResourceGroup -n $AzureDiskName |
| 199 | +Write-Host "VHD downloaded successfully." -ForegroundColor Green |
| 200 | + |
| 201 | +# Prompt to delete the temporary Azure resources to avoid ongoing costs |
| 202 | +$cleanup = Read-Host "`nDelete the temporary resource group '$AzureResourceGroup' in Azure to avoid costs? (Y/N)" |
| 203 | +if ($cleanup -ieq 'Y') { |
| 204 | + Write-Host "Deleting resource group '$AzureResourceGroup'..." -ForegroundColor Yellow |
| 205 | + az group delete -n $AzureResourceGroup --yes --no-wait --output none |
| 206 | + Write-Host "Resource group deletion initiated (running in background)." -ForegroundColor Green |
| 207 | +} else { |
| 208 | + Write-Host "Skipped. Remember to delete resource group '$AzureResourceGroup' manually to avoid costs." -ForegroundColor Yellow |
| 209 | +} |
| 210 | + |
| 211 | +# ================================================================================== |
| 212 | +# Step 4: Connect to Azure Stack Hub |
| 213 | +# ================================================================================== |
| 214 | +Write-Host "`n[Step 4] Connecting to Azure Stack Hub..." -ForegroundColor Cyan |
| 215 | + |
| 216 | +$hubEnvironment = Get-AzEnvironment -Name $EnvironmentName |
| 217 | +if ($hubEnvironment) { |
| 218 | + Remove-AzEnvironment -Name $EnvironmentName |
| 219 | +} |
| 220 | + |
| 221 | +$response = Invoke-RestMethod "${AdminArmEndpoint}/metadata/endpoints?api-version=1.0" |
| 222 | +Write-Host " ARM endpoint: $AdminArmEndpoint" |
| 223 | +Write-Host " Gallery endpoint: $($response.galleryEndpoint)" |
| 224 | + |
| 225 | +$hubAdminEnvironmentParams = @{ |
| 226 | + Name = $EnvironmentName |
| 227 | + ActiveDirectoryEndpoint = $response.authentication.loginEndpoint.TrimEnd('/') + "/" |
| 228 | + ActiveDirectoryServiceEndpointResourceId = $response.authentication.audiences[0] |
| 229 | + ResourceManagerEndpoint = $AdminArmEndpoint |
| 230 | + GalleryEndpoint = $response.galleryEndpoint |
| 231 | + GraphEndpoint = $response.graphEndpoint |
| 232 | + GraphAudience = $response.graphEndpoint |
| 233 | + EnableAdfsAuthentication = $response.authentication.loginEndpoint.TrimEnd("/").EndsWith("/adfs", [System.StringComparison]::OrdinalIgnoreCase) |
| 234 | + StorageEndpoint = $StorageEndpointDnsSuffix |
| 235 | + AzureKeyVaultDnsSuffix = $KeyVaultDnsSuffix |
| 236 | +} |
| 237 | +Add-AzEnvironment @hubAdminEnvironmentParams | Out-Null |
| 238 | +$hubEnvironment = Get-AzEnvironment -Name $EnvironmentName |
| 239 | + |
| 240 | +Clear-AzContext -Scope Process -Force -ErrorAction SilentlyContinue | Out-Null |
| 241 | +Connect-AzAccount -Environment $EnvironmentName -Credential $ServiceAdminCredential -Tenant $TenantID |
| 242 | +Write-Host "Connected to Azure Stack Hub." -ForegroundColor Green |
| 243 | +Get-AzSubscription | Format-Table -Property SubscriptionId, Name, TenantId |
| 244 | + |
| 245 | +# ================================================================================== |
| 246 | +# Step 5: Upload VHD to Azure Stack Hub storage account |
| 247 | +# ================================================================================== |
| 248 | +Write-Host "`n[Step 5] Uploading VHD to Azure Stack Hub..." -ForegroundColor Cyan |
| 249 | + |
| 250 | +New-AzResourceGroup -Name $HubResourceGroup -Location $HubLocation -Force | Out-Null |
| 251 | + |
| 252 | +New-AzStorageAccount ` |
| 253 | + -ResourceGroupName $HubResourceGroup ` |
| 254 | + -Name $HubStorageAccountName ` |
| 255 | + -Location $HubLocation ` |
| 256 | + -SkuName Standard_LRS |
| 257 | + |
| 258 | +$storageKeys = Get-AzStorageAccountKey -ResourceGroupName $HubResourceGroup -Name $HubStorageAccountName |
| 259 | +$ctx = New-AzStorageContext -StorageAccountName $HubStorageAccountName -StorageAccountKey $storageKeys[0].Value |
| 260 | + |
| 261 | +$osUri = "https://$HubStorageAccountName.blob.$StorageEndpointDnsSuffix/vhds/WS2025-datacenter.vhd" |
| 262 | + |
| 263 | +Write-Host "Uploading VHD (this may take a while)..." -ForegroundColor Yellow |
| 264 | +Add-AzVhd ` |
| 265 | + -LocalFilePath $VhdDownloadPath ` |
| 266 | + -ResourceGroupName $HubResourceGroup ` |
| 267 | + -Destination $osUri |
| 268 | + |
| 269 | +Write-Host "VHD uploaded successfully." -ForegroundColor Green |
| 270 | + |
| 271 | +# ================================================================================== |
| 272 | +# Step 6: Register as a platform image in Azure Stack Hub marketplace |
| 273 | +# ================================================================================== |
| 274 | +Write-Host "`n[Step 6] Registering platform image in Azure Stack Hub marketplace..." -ForegroundColor Cyan |
| 275 | + |
| 276 | +$storageAccount = Get-AzStorageAccount -ResourceGroupName $HubResourceGroup -Name $HubStorageAccountName |
| 277 | +$ctx = $storageAccount.Context |
| 278 | +$containerName = "vhds" |
| 279 | +$blobName = "WS2025-datacenter.vhd" |
| 280 | +$sasToken = New-AzStorageBlobSASToken ` |
| 281 | + -Container $containerName ` |
| 282 | + -Blob $blobName ` |
| 283 | + -Permission r ` |
| 284 | + -ExpiryTime (Get-Date).AddHours(24) ` |
| 285 | + -Context $ctx |
| 286 | +$osSasUri = "$($ctx.BlobEndPoint)$containerName/$blobName$sasToken" |
| 287 | + |
| 288 | +Add-AzsPlatformImage ` |
| 289 | + -Location $HubLocation ` |
| 290 | + -Publisher "MicrosoftWindowsServer" ` |
| 291 | + -Offer "WindowsServer" ` |
| 292 | + -Sku $selectedImage.sku ` |
| 293 | + -Version $selectedImage.version ` |
| 294 | + -OSType "Windows" ` |
| 295 | + -OSUri $osSasUri |
| 296 | + |
| 297 | +# Verify the image was registered |
| 298 | +$platformImages = Get-AzsPlatformImage |
| 299 | +$ws2025Image = $platformImages | Where-Object { |
| 300 | + $_.Publisher -eq "MicrosoftWindowsServer" -and |
| 301 | + $_.Offer -eq "WindowsServer" -and |
| 302 | + $_.Sku -eq $selectedImage.sku |
| 303 | +} |
| 304 | + |
| 305 | +if ($ws2025Image) { |
| 306 | + Write-Host "`nWindows Server 2025 ($($selectedImage.sku)) image registered successfully!" -ForegroundColor Green |
| 307 | + $ws2025Image | Format-List |
| 308 | +} else { |
| 309 | + Write-Host "`nWarning: Image registration could not be verified." -ForegroundColor Yellow |
| 310 | +} |
| 311 | + |
| 312 | +Write-Host "`nDone." -ForegroundColor Cyan |
0 commit comments