Home Linux & Systems Cybersecurity Cloud & DevOps Networks & Infrastructure SIEM & Monitoring DFIR & Threat Intel Development & Other All categories Projects About Tools

Disk management on Azure VMs with Terragrunt: how to map guest OS IDs to managed disks

Leer en espanol
Disk management on Azure VMs with Terragrunt: how to map guest OS IDs to managed disks

Table of contents

The problem: two worlds, two identifiers

A common scenario in teams managing Azure infrastructure with IaC: the Windows systems team (Wintel) opens a ticket requesting disk changes on a VM. The problem is that they speak different languages:

  • Wintel says: "Delete the disk with Msft Virtual Disk ID 60022480XXXXXXXXXXXXXXXXXXXXXXXX and expand the 32 GB one to 48 GB"
  • You see in Terraform: data1, data2, data3 with LUNs and sizes

The ID that Windows provides (60022480...) is a guest OS UniqueId that does not appear anywhere in your IaC code nor in the Azure portal. You cannot simply grep the repository and find the answer.

This article documents the complete process to resolve this mapping and execute the change safely with Terragrunt.

Key concepts

Before getting into it, let's clarify the terminology:

ConceptWhere it livesExample
UniqueId (Guest)Inside Windows60022480B94E649D631F68911AA694D1
Managed DiskAzure Resource Managervm-name-data2-1x
LUNVM configuration in Azure10
DiskNumberWindows Disk Management2
Name in IaCTerragrunt/Terraformdata2

The key is to understand that DiskNumber in Windows ≠ LUN in Azure. They are independent concepts that require cross-correlation.

Step 1: Inventory from Azure (source of truth)

The first thing is to obtain which disks the VM actually has in Azure:

BASH
az account set --subscription "<subscription-id>"

az vm show -g <resource-group> -n <vm-name> \
  --query "storageProfile.dataDisks[].{LUN:lun,Name:name,SizeGB:diskSizeGb}" \
  -o table

Example result:

CODE
LUN    Name                  SizeGB
-----  --------------------  --------
1      vm-name-data1-1x      128
10     vm-name-data2-1x      32
11     vm-name-data3-1x      128

We already know: 3 data disks in Azure with LUN 1, 10 and 11.

Step 2: Inventory from the guest OS (RunCommand)

If you don't have RDP access to the VM, you can run a PowerShell script remotely with az vm run-command invoke. This step usually requires several iterations because the environment does not always return what is expected.

2.1 Launch RunCommand from Azure CLI

BASH
az vm run-command invoke \
  -g <resource-group> \
  -n <vm-name> \
  --command-id RunPowerShellScript \
  --scripts "Get-Disk | Select-Object Number, @{N='SizeGB';E={[math]::Round(\$_.Size/1GB,0)}}, UniqueId | Format-Table -AutoSize"

The response comes in JSON with the stdout and stderr fields. If the script fails, the error will appear in stderr.

2.2 The truncated script error (first failed attempt)

On the first attempt, a more complex script with Where-Object to filter by a specific ID was sent. The result was:

CODE
You must provide a value expression following the '-eq' operator.
Missing closing '}' in statement block or type definition.

Why does it happen? The script was cut in the middle of a Where-Object { $_.Index -eq ... } block. RunCommand has a practical limit on the size of the inline --scripts, and special characters like $, {, } need proper escaping depending on the shell from which it is launched.

Solution: Use the complete, untruncated script with explicitly cast variables:

POWERSHELL
Get-Disk | ForEach-Object {
  $d = $_
  $wmi = Get-CimInstance Win32_DiskDrive |
    Where-Object { $_.Index -eq [int]$d.Number } |
    Select-Object -First 1
  [pscustomobject]@{
    DiskNumber = $d.Number
    SizeGB     = [math]::Round($d.Size / 1GB, 0)
    UniqueId   = $d.UniqueId
    LUN        = $wmi.SCSITargetId
    Model      = $wmi.Model
  }
} | Sort-Object DiskNumber | Format-Table -AutoSize

Tip: If the script is long, it is more reliable to pass it as a file with --scripts @script.ps1 instead of inline.

2.3 The confusing result: LUN=0 for all

The second attempt ran correctly, but returned something unexpected when querying a specific disk by its UniqueId:

CODE
TargetId     : 600224808C5B855329AF53DEFF061AF0
DiskNumber   : 1
UniqueId     : 600224808C5B855329AF53DEFF061AF0
SerialNumber :
LUN          : 0

This is where many get confused: the disk shows up with LUN 0, but in Azure the data disks of this VM are on LUN 1, 10 and 11. How is that possible?

Explanation: SCSITargetId (which is what Win32_DiskDrive exposes as LUN inside the guest) is not the same as the Azure LUN. In many Azure Windows VMs:

  • The guest SCSI controller reports SCSITargetId = 0 for all Msft Virtual Disk disks
  • The real Azure LUN is only visible from the Azure API (the az vm show from Step 1)
  • Trying to map using this field will lead you to incorrect decisions

2.4 Final complete inventory (the decisive evidence)

Given the ambiguity of the LUN in the guest, the correct approach is to obtain the complete inventory of all disks with their sizes and UniqueIds:

CODE
DiskNumber  SizeGB  UniqueId                                  LUN  Model
----------  ------  --------                                  ---  -----
0           128     IDE\DISKVIRTUAL_HD...                     0    Virtual HD
1           128     600224808C5B855329AF53DEFF061AF0          0    Msft Virtual Disk
2           32      60022480B94E649D631F68911AA694D1          0    Msft Virtual Disk
3           128     60022480B16D7C3BE95C7D51B2235E9A          0    Msft Virtual Disk

Now we do have what we need: UniqueId + size for each disk. This, combined with the Azure inventory, allows us to do the correlation.

Observations:

  • DiskNumber 0 with ID IDE\DISK... is the OS disk (do not touch)
  • The three remaining ones are the managed data disks (they all report LUN 0 in the guest, but that is irrelevant)
  • The 32 GB one is unequivocally data2
  • Between the two 128 GB ones, Wintel explicitly identified which one to delete by its UniqueId

Step 3: Cross-correlation (the mapping table)

With evidence from both sides, we build the decision table. This is the most important artifact of the process — it is what justifies the change in an audit:

Guest UniqueIdGuest SizeAzure DiskIaC NameAzure LUNAction
...1AF0128 GBvm-name-data1-1xdata11Delete
...94D132 GBvm-name-data2-1xdata210Resize → 48 GB
...5E9A128 GBvm-name-data3-1xdata311Keep

Decision logic

  1. 32 GB disk: There is only one in Azure (data2, LUN 10) and one in the guest (UniqueId ...94D1). Unequivocal mapping.
  2. 128 GB disk to delete: Wintel provided the explicit UniqueId (...1AF0). In Azure there are two 128 GB disks (data1 on LUN 1 and data3 on LUN 11). We take data1 as the deletion target based on the combined evidence.
  3. 128 GB disk to keep: data3 (LUN 11, UniqueId ...5E9A) was not mentioned in the request; it stays unchanged.

Golden rule: When there are multiple disks of the same size, never decide by size alone. Require the systems team to confirm the UniqueId, the drive letter, or the volume label of the affected disk.

Step 4: Implementation in Terragrunt

In the Windows VM module, the data disks are defined in the dynamic_data_disks block of the terragrunt.hcl:

State BEFORE the change

HCL
dynamic_data_disks = {
  "data1" = {
    location                      = "North Europe"
    storage_account_type          = "StandardSSD_LRS"
    create_option                 = "Empty"
    managed_disk_size_gb          = 128
    public_network_access_enabled = false
    os_type                       = "Windows"
    lun                           = 1
    caching                       = "ReadWrite"
  }
  "data2" = {
    location                      = "North Europe"
    storage_account_type          = "StandardSSD_LRS"
    create_option                 = "Empty"
    managed_disk_size_gb          = 32
    public_network_access_enabled = false
    os_type                       = "Windows"
    lun                           = 10
    caching                       = "ReadWrite"
  }
  "data3" = {
    location                      = "North Europe"
    storage_account_type          = "StandardSSD_LRS"
    create_option                 = "Empty"
    managed_disk_size_gb          = 128
    public_network_access_enabled = false
    os_type                       = "Windows"
    lun                           = 11
    caching                       = "ReadWrite"
  }
}

State AFTER the change

HCL
dynamic_data_disks = {
  "data2" = {
    location                      = "North Europe"
    storage_account_type          = "StandardSSD_LRS"
    create_option                 = "Empty"
    managed_disk_size_gb          = 48       # Before: 32
    public_network_access_enabled = false
    os_type                       = "Windows"
    lun                           = 10
    caching                       = "ReadWrite"
  }
  "data3" = {
    location                      = "North Europe"
    storage_account_type          = "StandardSSD_LRS"
    create_option                 = "Empty"
    managed_disk_size_gb          = 128
    public_network_access_enabled = false
    os_type                       = "Windows"
    lun                           = 11
    caching                       = "ReadWrite"
  }
}

Changes made:

  • Removed: the entire data1 block (the module handles the detach + delete)
  • Modified: managed_disk_size_gb of data2 from 32 → 48
  • Unchanged: data3 remains intact

Step 5: Pre-apply validation

BASH
terragrunt plan

The plan should show exactly:

  • destroy: disk data1 (LUN 1, 128 GB)
  • update in-place: disk data2, size 32 → 48
  • No changes in data3

If any other unexpected change appears: STOP. Check whether there is state drift or inherited variables that have changed.

Step 6: Apply and post-change verification

BASH
terragrunt apply

Azure verification

BASH
az vm show -g <resource-group> -n <vm-name> \
  --query "storageProfile.dataDisks[].{LUN:lun,Name:name,SizeGB:diskSizeGb}" \
  -o table

Expected:

CODE
LUN    Name                  SizeGB
-----  --------------------  --------
10     vm-name-data2-1x      48
11     vm-name-data3-1x      128

Guest verification (request from Wintel)

  1. Rescan disks in Disk Management
  2. Confirm that the deleted disk no longer appears
  3. Confirm that the expanded disk shows 48 GB
  4. Extend the partition at the filesystem level (the Azure resize does not do it automatically)

Rollback strategy

If something goes wrong post-apply:

  1. Don't touch anything else — do not try to fix on the fly
  2. Restore data1 in dynamic_data_disks with its original values
  3. Revert data2 to 32 GB if necessary
  4. terragrunt plan → verify that the plan is coherent
  5. terragrunt apply

Important note: If the deleted disk had data, the rollback will create a new empty disk. The data will have been lost. That is why it is critical to confirm with the systems team that the disk is free of data before the apply.

Common mistakes and lessons learned

1. Trusting the guest's SCSITargetId

In many Azure Windows VMs, Win32_DiskDrive.SCSITargetId returns 0 for all data disks. Do not use that value as the source of truth for the LUN.

2. Incomplete RunCommand scripts

If you copy multi-line PowerShell scripts into RunCommand, make sure the Where-Object { ... } block is complete. A cut in the middle produces cryptic errors:

CODE
You must provide a value expression following the '-eq' operator.
Missing closing '}' in statement block or type definition.

3. Two disks of the same size

When there are multiple disks with the same size (like two 128 GB ones), you cannot map by size alone. You need the guest's UniqueId or, alternatively, for Wintel to confirm the associated drive letter/volume.

4. Resize does not extend the partition

Azure can resize the managed disk, but the filesystem inside Windows will still see the previous size until someone runs Extend-Volume or does it from Disk Management.

Operational checklist

Use this checklist for any disk change on Azure VMs managed with IaC:

  • [ ] Subscription and Resource Group confirmed
  • [ ] Azure inventory (az vm show) saved
  • [ ] Guest inventory (PowerShell) saved
  • [ ] Mapping table completed and reviewed
  • [ ] IaC change reviewed (ideally by a second pair of eyes)
  • [ ] terragrunt plan with no unexpected changes
  • [ ] Backup/snapshot verified before destructive operation
  • [ ] Apply executed in an approved change window
  • [ ] Post-apply verification in Azure
  • [ ] Post-apply verification with the systems team
  • [ ] Documentation archived

Conclusion

Mapping disk IDs between the Windows guest and Azure managed disks is not trivial, especially when the identifiers each team handles are completely different. The key lies in:

  1. Don't assume — always cross-check evidence from both sides
  2. Document the decision — the mapping table is your insurance against audits
  3. Validate before destroying — a clean plan costs nothing, a disk deleted by mistake costs a lot

With a well-defined flow and Terragrunt/Terraform as the source of truth of the infrastructure, these changes go from being risky operations to controlled, repeatable procedures.

Comments