Skip to content

Commit 6f9bc76

Browse files
authored
Merge pull request #711 from NeilBird/hub-example-steps-for-ws2025-image
Add example: Windows Server 2025 GEN1 platform image creation for Azure Stack Hub
2 parents 8fd59ad + 09b274e commit 6f9bc76

3 files changed

Lines changed: 672 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)