Post

Automating Hyper‑V VM Adoption into Azure Local

Automating Hyper‑V VM Adoption into Azure Local

Quick view on how automating this thing would look for more than a few VMs.

First, let’s split the scripts into 4 pieces - they can be gobbled together if needed, but that makes walkthroughs harder and debugging an all‑in‑one script usually feels like fighting a zergling with a USB cable.

If you’d like, you could also keep things split, mash them together into a monster script, add a fancy VM selection process, or even invoke CLI straight from PowerShell and run it from a single spot.

Basically: use your favorite Clippy 2.0 / HAL 9000 mashup to automate however you like. I’m more on the “keep it modular” side - easier to troubleshoot, tweak, or figure out what the heck I was trying to do three months later.

The 4 pieces:

  1. Export Hyper‑V VM configs
  2. Create placeholders
  3. Swap the disks
  4. Create the Azure Local VMs (and turn them on if you’re feeling bold)

Note: 1 and 3 are ran on the Hyper-V host. 2 and 4 use Azure CLI (Cloud Shell is easiest).

Below, I’m adopting/moving over 3 VMs - S01/S02/S03. They have a simple setup, with a single OS and single NIC.


1. Export Hyper‑V VM configs

Collect VM info and export as JSON, including NIC config - pretty self‑explanatory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# PowerShell
$vms = @()
Get-VM | ForEach-Object {
    $vm = $_
    $cpu = Get-VMProcessor -VMName $vm.Name
    $mem = Get-VMMemory -VMName $vm.Name
    $hdds = Get-VMHardDiskDrive -VMName $vm.Name | Sort-Object ControllerLocation
    $nics = Get-VMNetworkAdapter -VMName $vm.Name

    $osDisk = $null
    $dataDisks = @()
    $nicConfigs = @()

    if ($hdds.Count -gt 0) {
        $osVhd = Get-VHD -Path $hdds[0].Path
        $osDiskBase = [System.IO.Path]::GetFileNameWithoutExtension($hdds[0].Path)
        $osDiskExt  = [System.IO.Path]::GetExtension($hdds[0].Path)
        $osDisk = @{
            name   = "${osDiskBase}${osDiskExt}"
            sizeGB = [int][math]::Ceiling($osVhd.Size / 1GB)
            path   = $hdds[0].Path
        }
        foreach ($d in $hdds[1..($hdds.Count-1)]) {
            $vhd = Get-VHD -Path $d.Path
            $diskBase = [System.IO.Path]::GetFileNameWithoutExtension($d.Path)
            $diskExt  = [System.IO.Path]::GetExtension($d.Path)
            $dataDisks += @{
                name   = "${diskBase}${diskExt}"
                sizeGB = [int][math]::Ceiling($vhd.Size / 1GB)
                path   = $d.Path
            }
        }
    }

    foreach ($nic in $nics) {
        $nicConfigs += @{
            name         = $nic.Name
            macAddress   = $nic.MacAddress
            switchName   = $nic.SwitchName
            ipAddresses  = $nic.IPAddresses
        }
    }

    $vms += @{
        VMName     = $vm.Name
        cpuCount   = $cpu.Count
        memStartup = [int][math]::Ceiling($mem.Startup / 1GB)
        memMin     = [int][math]::Ceiling($mem.Minimum / 1GB)
        memMax     = [int][math]::Ceiling($mem.Maximum / 1GB)
        memDynamic = $mem.DynamicMemoryEnabled
        osDisk     = $osDisk
        dataDisks  = $dataDisks
        nics       = $nicConfigs
    }
}
$vms | ConvertTo-Json -Depth 4 | Out-File .\vm_inventory.json

2. Create placeholders

Here’s where we pre‑stage resources in Azure Local. Think of it like setting the dinner table before serving the food - except the “food” is a 120GB VHDX you’ll be dragging across a cluster in the next step.

Tip: NIC names aren’t really usually enforced on the Hyper‑V side, so this is a good time to normalize them. Same for disk names - pick a pattern you’ll reuse.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Azure CLI (run in Cloud Shell or anywhere az is authenticated)
# Note: reading from vm_inventory.json created in step 1

$inventory = Get-Content .\vm_inventory.json | ConvertFrom-Json

$resourceGroup = "RGname"
$customLocationID = "/subscriptions/<subID>/resourceGroups/<RGname>/providers/Microsoft.ExtendedLocation/customLocations/<s-cluster-customlocation>"
$location = "AzLocalRegion"
$storagePathId = "/subscriptions/<subID>/resourceGroups/<RGname>/providers/Microsoft.AzureStackHCI/storageContainers/<yourStoragePath>"
$diskFileFormat = "vhdx"
$subnetId = "lnetname"

foreach ($vm in $inventory) {
    $vmName = $vm.VMName
    $osDiskName = ($vm.osDisk.name -replace '\.vhdx$', '') +"-"+ $vm.VMName
    $dataDisks = $vm.dataDisks
    $nics = $vm.nics

    # Create NICs
    foreach ($nic in $nics) {
        $nicName = "${vmName}-${nic.name}"
        az stack-hci-vm network nic create `
            --resource-group $resourceGroup `
            --custom-location $customLocationID `
            --location $location `
            --name "$vmName-NIC" `
            --subnet-id $subnetId
    }

    # Create OS Disk
    az stack-hci-vm disk create `
        --resource-group $resourceGroup `
        --custom-location $customLocationID `
        --location $location `
        --size-gb $vm.osDisk.sizeGB `
        --name $osDiskName `
        --storage-path-id $storagePathId `
        --disk-file-format $diskFileFormat

    # Create Data Disks
    foreach ($d in $dataDisks) {
        az stack-hci-vm disk create `
            --resource-group $resourceGroup `
            --custom-location $customLocationID `
            --location $location `
            --size-gb $d.sizeGB `
            --name "${d.name}-toBeSwapped" `
            --storage-path-id $storagePathId `
            --disk-file-format $diskFileFormat `
    }
    Write-Host "AzLocal resources created for $($vm.VMName)"
}

3. Swap the disks

Time to shut down the Hyper‑V VMs and move the bits where Azure Local expects them … or will expect them … until they change them … maybe.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# PowerShell
# Shutdown all VMs first that we are targetting - which are listed in the inventory
Stop-VM -Name (Get-Content .\vm_inventory.json | ConvertFrom-Json).VMName -Force

# Define your subscription and resource group which will be used to create he Disk GUIDoName
$subID = "<subID>"
$rgName = "<RGName>"

# New disk destination folder -- check storage path for a GUID looking folder
$newDiskRoot = "C:\ClusterStorage\<storagePathDirectory>\<diskSpecificGUID>"

$inventory = Get-Content .\vm_inventory.json | ConvertFrom-Json

foreach ($vm in $inventory) {
    $vmName = $vm.VMName

    # OS Disk
    $osDisk = $vm.osDisk
    $osDiskSrc = $osDisk.path
    $osDiskBase = ($osDisk.name -replace '\.vhdx$', '')
    $osDiskGUID = "$subID-$rgName"
    $osDiskDest = Join-Path $newDiskRoot "$osDiskBase-$vmName-$osDiskGUID.vhdx"
    Move-Item -Path $osDiskSrc -Destination $osDiskDest -Force

    # Data Disks
    foreach ($d in $vm.dataDisks) {
        $dataDiskSrc = $d.path
        $dataDiskBase = ($d.name -replace '\.vhdx$', '')
        $dataDiskGUID = "$subID-$rgName"
        $dataDiskDest = Join-Path $newDiskRoot "$dataDiskBase-$vmName-$dataDiskGUID.vhdx"
        Move-Item -Path $dataDiskSrc -Destination $dataDiskDest -Force
    }
}

Write-Host "All disks moved and renamed for AzLocal adoption."

4. Create AzLocal VMs

Now for the fun part: adopting it as an AzLocal VM.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# PowerShell
$inventory = Get-Content .\vm_inventory.json | ConvertFrom-Json

$subID = "<subID>"
$rgName = "<RGName>"
$resourceGroup = "<RGName>"
$customLocationID = "/subscriptions/<subID>/resourcegroups/<RGName>/providers/microsoft.extendedlocation/customlocations/<AzLocalRegion>"
$location = "<azLocalLocation>"
$storagePathId = "/subscriptions/<subID>/resourceGroups/<RGName>/providers/microsoft.azurestackhci/storagecontainers/<storagePathDirectory>"
$osType = "windows" # or "linux" as appropriate

foreach ($vm in $inventory) {
    $vmName = $vm.VMName

    # OS Disk name (matches what you used for Move-Item)
    $osDiskName = ($vm.osDisk.name -replace '\.vhdx$', '') + "-$vmName"
    $osDiskNameid = "/subscriptions/$subID/resourceGroups/$rgName/providers/Microsoft.AzureStackHCI/virtualHardDisks/$osDiskName"
    
    #to confirm $osdisknameID - go to the AzLocal instance, find Disks, and click the JSON option to show the ID

    # NICs (assume first NIC for simplicity, adjust as needed)
    $nicNames = @()
    foreach ($nic in $vm.nics) {
        $nicNames += "${vmName}-NIC"
    }
    $nicRefs = $nicNames -join ','
    $nic1id = "/subscriptions/$subID/resourceGroups/$rgName/providers/Microsoft.AzureStackHCI/networkInterfaces/$nicRefs"

    # Data disks (you'll need to add the datadisks after creating the VM - use 'az stack-hci-vm disk attach')

    # Create VM
    az stack-hci-vm create `
        --resource-group $resourceGroup `
        --custom-location $customLocationID `
        --location $location `
        --storage-path-id $storagePathId `
        --name $vmName `
        --os-disk-name $osDiskNameid `
        --os-type $osType `
        --nics $nic1id

    Write-Host "AzLocal VM created for $vmName"
}

process overview

This post is licensed under CC BY 4.0 by the author.