Azure VM Disk Usage Report
Overview
This PowerShell script queries disk usage (capacity and used space) from all Azure VMs across all subscriptions and outputs a single consolidated CSV file. It authenticates using a service principal and auto-discovers all subscriptions, then all running VMs within them.
The script uses Azure RunCommand to execute a disk query inside each VM operating system, retrieving real filesystem used/free space per drive letter. Unreachable or errored VMs are skipped with a warning and logged to a separate CSV.
Output
The script produces a CSV file named VMDiskUsage_YYYY-MM-DD.csv with the following columns:
| Column | Description |
|---|---|
| SubscriptionName | Display name of the Azure subscription |
| SubscriptionId | GUID of the subscription |
| ResourceGroup | Resource group containing the VM |
| VMName | Name of the virtual machine |
| OSType | Windows or Linux |
| VMSize | Azure VM size (e.g. Standard_D2s_v3) |
| Location | Azure region |
| DiskLetter | Drive letter (Windows) or mount point (Linux) |
| DiskLabel | Volume label if available |
| CapacityGB | Total disk capacity in GB |
| UsedGB | Used space in GB |
| FreeGB | Free space in GB |
| PercentUsed | Percentage of disk used |
Prerequisites
PowerShell 7+
This script requires PowerShell 7 or later. It will not run on Windows PowerShell 5.1. Download the latest version from the PowerShell GitHub releases page.
App Registration (Service Principal)
You need an Azure AD / Entra ID app registration with a client secret. This is the same service principal used by the Carbon Optimization script. You will need the Tenant ID, Client ID, and Client Secret.
RBAC Role Assignment
The app registration needs one of the following roles assigned at the Management Group level:
- Virtual Machine Contributor (broader permissions)
- Virtual Machine RunCommand Operator + Reader (least privilege, recommended)
Azure VM Agent
The Azure VM agent must be running on each target VM. This is installed by default on all Azure VMs. VMs must be in a Running state for RunCommand to execute — deallocated or stopped VMs are skipped automatically.
How It Works
1. Authentication
The script authenticates against Azure AD using OAuth 2.0 client credentials flow. It requests a bearer token from login.microsoftonline.com with the service principal credentials. The token is automatically refreshed when it approaches expiry (60-second safety buffer), keeping long-running executions alive without interruption.
2. Subscription Discovery
Using the Azure Management REST API, the script auto-discovers all subscriptions available to the service principal. By default, only Enabled subscriptions are queried. The API supports pagination, so all subscriptions are retrieved regardless of count.
3. VM Enumeration
For each subscription, the script lists all VMs using the Compute provider API. It then checks the power state of each VM individually via instance view calls. Only VMs in the PowerState/running state are processed — deallocated VMs cannot execute RunCommands and are skipped with a console message.
4. RunCommand Execution
The script uses the Azure control plane RunCommand feature to execute OS-level disk queries inside each VM. This approach:
- Requires no WinRM or SSH configuration
- Requires no firewall rules or NSG changes
- Works on both Windows and Linux VMs
- Uses
Get-PSDriveon Windows anddfon Linux
Each RunCommand call is asynchronous — the script submits the command, then polls the Azure-AsyncOperation URL until the result is returned or the timeout is reached (default: 120 seconds). Each VM typically takes 30–90 seconds.
5. Output Parsing & CSV Export
The disk query output uses a pipe-delimited format that is parsed into structured objects. Linux pseudo-filesystems (tmpfs, devtmpfs, etc.) are automatically filtered out. All results are consolidated into a single CSV file, with skipped VMs logged to a separate file.
Configuration
Before running the script, replace the four placeholder values at the top of the script:
| Variable | Description | Example |
|---|---|---|
$TenantId | Your Azure AD / Entra ID tenant ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
$ClientId | App registration client ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
$ClientSecret | App registration secret value | your-secret-value |
$OutputFolder | Local folder path for CSV output | C:\DiskReports |
Optional Settings
| Variable | Default | Description |
|---|---|---|
$EnabledOnly | $true | Only query VMs in Enabled subscriptions |
$RunningOnly | $true | Only query running VMs (recommended) |
$RunCommandTimeoutSeconds | 120 | Max seconds to wait per VM RunCommand |
$RunCommandPollIntervalSeconds | 5 | Polling interval between status checks |
RunCommand Notes
RunCommand uses the Azure control plane, so no WinRM, SSH, or firewall rules are needed. It works on both Windows and Linux VMs. Each call may take 30–90 seconds per VM. Linux VMs use the
dfcommand while Windows VMs useGet-PSDrive/ WMI.
The Script
#Requires -Version 7.0
<#
.SYNOPSIS
Queries disk usage (capacity and used space) from all Azure VMs across all
subscriptions and outputs a single consolidated CSV file.
.DESCRIPTION
Authenticates using the same service principal as the Carbon Optimization script.
Auto-discovers all subscriptions, then all running VMs within them.
Uses Azure RunCommand to execute a disk query inside each VM OS, retrieving
real filesystem used/free space per drive letter.
Unreachable or errored VMs are skipped with a warning and logged separately.
Output file:
VMDiskUsage_YYYY-MM-DD.csv
CSV columns:
SubscriptionName, SubscriptionId, ResourceGroup, VMName, OSType,
VMSize, Location, DiskLetter, DiskLabel, CapacityGB, UsedGB, FreeGB, PercentUsed
.NOTES
Prerequisites:
- PowerShell 7+
- Same app registration as Carbon Optimization script
- App registration needs one of the following roles at Management Group level:
'Virtual Machine Contributor' (broader)
'Virtual Machine RunCommand Operator' + 'Reader' (least privilege)
- Azure VM agent must be running on each VM (installed by default on all Azure VMs)
- VMs must be in a 'Running' state for RunCommand to execute
(deallocated/stopped VMs are skipped automatically)
RunCommand notes:
- Uses the Azure control plane — no WinRM, no firewall rules needed
- Works on both Windows and Linux VMs
- Each RunCommand call may take 30-90 seconds per VM
- Linux VMs use 'df' command; Windows VMs use Get-PSDrive / WMI
#>
# ============================================================
# CONFIGURATION — Replace all <PLACEHOLDER> values
# ============================================================
# Azure AD / Entra ID tenant ID
$TenantId = "<ADD YOUR TENANT ID HERE>"
# Service principal credentials (same app registration as Carbon script)
$ClientId = "<ADD YOUR APP REGISTRATION CLIENT ID HERE"
$ClientSecret = "<ADD YOUR APP REGISTRATION SECRET VALUE HERE"
# Local output folder — created if it doesn't exist
$OutputFolder = "<ADD YOUR SAVE LOCATION" # e.g. "C:\DiskReports"
# ============================================================
# OPTIONAL SETTINGS
# ============================================================
# Only query VMs in 'Enabled' subscriptions
$EnabledOnly = $true
# Only query VMs that are currently running (recommended — deallocated VMs cannot run commands)
$RunningOnly = $true
# Maximum seconds to wait for RunCommand to complete per VM
$RunCommandTimeoutSeconds = 120
# How long to wait between polling RunCommand status (seconds)
$RunCommandPollIntervalSeconds = 5
# API versions
$SubscriptionsApiVersion = "2022-12-01"
$VMListApiVersion = "2023-07-01"
$RunCommandApiVersion = "2023-07-01"
# ============================================================
# FUNCTIONS
# ============================================================
function Get-BearerToken {
param (
[string]$TenantId,
[string]$ClientId,
[string]$ClientSecret,
[switch]$Silent
)
if (-not $Silent) { Write-Host "Requesting bearer token..." -ForegroundColor Cyan }
$TokenUrl = "https://login.microsoftonline.com/$TenantId/oauth2/token"
$Body = @{
grant_type = "client_credentials"
client_id = $ClientId
client_secret = $ClientSecret
resource = "https://management.azure.com"
}
try {
$Response = Invoke-RestMethod -Uri $TokenUrl -Method POST -Body $Body -ContentType "application/x-www-form-urlencoded"
# Return both the token and its expiry time as an object
$ExpiresAt = (Get-Date).AddSeconds([int]$Response.expires_in - 60) # 60s safety buffer
if (-not $Silent) { Write-Host "Token acquired. Expires at $($ExpiresAt.ToString('HH:mm:ss'))." -ForegroundColor Green }
return [PSCustomObject]@{
AccessToken = $Response.access_token
ExpiresAt = $ExpiresAt
}
}
catch {
Write-Error "Failed to retrieve bearer token: $_"
exit 1
}
}
function Get-ValidToken {
<#
.SYNOPSIS
Returns a valid bearer token string, refreshing automatically if within
60 seconds of expiry. Keeps long-running scripts alive without interruption.
#>
param (
[PSCustomObject]$TokenObj,
[string]$TenantId,
[string]$ClientId,
[string]$ClientSecret
)
if ((Get-Date) -ge $TokenObj.ExpiresAt) {
Write-Host " Token expiring — refreshing silently..." -ForegroundColor DarkYellow
$TokenObj = Get-BearerToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret -Silent
Write-Host " Token refreshed. New expiry: $($TokenObj.ExpiresAt.ToString('HH:mm:ss'))" -ForegroundColor DarkYellow
}
return $TokenObj
}
function Get-AllSubscriptions {
param (
[string]$Token,
[string]$ApiVersion,
[bool]$EnabledOnly
)
Write-Host "Discovering subscriptions..." -ForegroundColor Cyan
$Uri = "https://management.azure.com/subscriptions?api-version=$ApiVersion"
$Headers = @{ "Authorization" = "Bearer $Token"; "Content-Type" = "application/json" }
$AllSubscriptions = @()
do {
try { $Response = Invoke-RestMethod -Uri $Uri -Method GET -Headers $Headers }
catch { Write-Error "Failed to retrieve subscriptions: $_"; exit 1 }
$Subs = $Response.value
if ($EnabledOnly) { $Subs = $Subs | Where-Object { $_.state -eq "Enabled" } }
$AllSubscriptions += $Subs
$Uri = $Response.nextLink
} while ($Uri)
Write-Host "Found $($AllSubscriptions.Count) subscription(s)." -ForegroundColor Green
return $AllSubscriptions
}
function Get-VMsInSubscription {
<#
.SYNOPSIS
Returns all VMs in a subscription along with their power state.
.NOTES
Two-step approach:
Step 1 — List all VMs without expand (bulk list does not support instanceView
expand on all subscription types, causing HTTP 400).
Step 2 — Per-VM instance view call to get power state individually.
#>
param (
[string]$Token,
[string]$SubscriptionId,
[string]$ApiVersion,
[bool]$RunningOnly
)
$Headers = @{ "Authorization" = "Bearer $Token"; "Content-Type" = "application/json" }
# Step 1 — List all VMs without expand
$Uri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Compute/virtualMachines?api-version=$ApiVersion"
$AllVMs = @()
do {
try {
$Response = Invoke-RestMethod -Uri $Uri -Method GET -Headers $Headers
$AllVMs += $Response.value
$Uri = $Response.nextLink
}
catch {
Write-Warning " Failed to list VMs in subscription $SubscriptionId : $_"
return @()
}
} while ($Uri)
if ($AllVMs.Count -eq 0) { return @() }
if (-not $RunningOnly) { return $AllVMs }
# Step 2 — Check power state per VM via individual instance view call
$RunningVMs = @()
foreach ($VM in $AllVMs) {
$VMUri = "https://management.azure.com$($VM.id)?api-version=$ApiVersion&`$expand=instanceView"
try {
$VMDetail = Invoke-RestMethod -Uri $VMUri -Method GET -Headers $Headers
$PowerState = ($VMDetail.properties.instanceView.statuses |
Where-Object { $_.code -like "PowerState/*" } |
Select-Object -First 1).code
if ($PowerState -eq "PowerState/running") {
$VM.properties | Add-Member -NotePropertyName instanceView -NotePropertyValue $VMDetail.properties.instanceView -Force
$RunningVMs += $VM
}
else {
Write-Host " Skipping $($VM.name) — power state: $PowerState" -ForegroundColor DarkGray
}
}
catch {
Write-Warning " Could not get instance view for $($VM.name) — including VM anyway."
$RunningVMs += $VM
}
}
return $RunningVMs
}
function Invoke-VMRunCommand {
<#
.SYNOPSIS
Runs a script inside a VM using Azure RunCommand and waits for the result.
Returns the stdout output string, or $null on failure.
#>
param (
[string]$Token,
[string]$SubscriptionId,
[string]$ResourceGroup,
[string]$VMName,
[string]$OSType, # "Windows" or "Linux"
[string]$ApiVersion,
[int]$TimeoutSeconds,
[int]$PollIntervalSeconds
)
$Headers = @{ "Authorization" = "Bearer $Token"; "Content-Type" = "application/json" }
# Script to run inside the VM — Windows uses PowerShell, Linux uses bash
if ($OSType -eq "Windows") {
$CommandId = "RunPowerShellScript"
$Script = @'
Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Used -ne $null } | ForEach-Object {
$TotalGB = [math]::Round(($_.Used + $_.Free) / 1GB, 2)
$UsedGB = [math]::Round($_.Used / 1GB, 2)
$FreeGB = [math]::Round($_.Free / 1GB, 2)
$Pct = if (($_.Used + $_.Free) -gt 0) { [math]::Round(($_.Used / ($_.Used + $_.Free)) * 100, 1) } else { 0 }
$Label = if ($_.Description) { $_.Description } else { "" }
"$($_.Name)|$Label|$TotalGB|$UsedGB|$FreeGB|$Pct"
}
'@
}
else {
# Linux
$CommandId = "RunShellScript"
$Script = @'
df -BG --output=target,size,used,avail,pcent | tail -n +2 | while read target size used avail pcent; do
size_gb=$(echo $size | tr -d 'G')
used_gb=$(echo $used | tr -d 'G')
avail_gb=$(echo $avail | tr -d 'G')
pct=$(echo $pcent | tr -d '%')
echo "$target||$size_gb|$used_gb|$avail_gb|$pct"
done
'@
}
$RunUri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Compute/virtualMachines/$VMName/runCommand?api-version=$ApiVersion"
$Body = @{
commandId = $CommandId
script = @($Script)
} | ConvertTo-Json -Depth 5
# Submit the RunCommand — Azure returns 202 Accepted with an async operation URL
try {
$SubmitResponse = Invoke-WebRequest -Uri $RunUri -Method POST -Headers $Headers -Body $Body -ContentType "application/json" -UseBasicParsing
$AsyncUrl = $SubmitResponse.Headers["Azure-AsyncOperation"]
if (-not $AsyncUrl) { $AsyncUrl = $SubmitResponse.Headers["Location"] }
}
catch {
Write-Warning " RunCommand submission failed for $VMName : $_"
return $null
}
if (-not $AsyncUrl) {
Write-Warning " No async URL returned for $VMName. Cannot poll for result."
return $null
}
# Poll until complete or timeout
$Elapsed = 0
while ($Elapsed -lt $TimeoutSeconds) {
Start-Sleep -Seconds $PollIntervalSeconds
$Elapsed += $PollIntervalSeconds
try {
$PollResponse = Invoke-RestMethod -Uri $AsyncUrl -Method GET -Headers $Headers
}
catch {
Write-Warning " Polling failed for $VMName : $_"
return $null
}
$Status = $PollResponse.status
if ($Status -eq "Succeeded") {
# Extract stdout from the operation result
$Output = $PollResponse.properties.output.value |
Where-Object { $_.code -eq "ComponentStatus/StdOut/succeeded" } |
Select-Object -ExpandProperty message
return $Output
}
elseif ($Status -eq "Failed" -or $Status -eq "Canceled") {
$ErrMsg = $PollResponse.error.message
Write-Warning " RunCommand $Status for $VMName : $ErrMsg"
return $null
}
Write-Host " Waiting... ($Elapsed/$TimeoutSeconds s)" -ForegroundColor DarkGray
}
Write-Warning " RunCommand timed out after $TimeoutSeconds seconds for $VMName."
return $null
}
function Parse-DiskOutput {
<#
.SYNOPSIS
Parses the pipe-delimited output from the RunCommand script into disk objects.
#>
param (
[string]$RawOutput,
[string]$VMName,
[string]$SubscriptionName,
[string]$SubscriptionId,
[string]$ResourceGroup,
[string]$OSType,
[string]$VMSize,
[string]$Location
)
$Rows = @()
foreach ($Line in ($RawOutput -split "`n")) {
$Line = $Line.Trim()
if (-not $Line) { continue }
$Parts = $Line -split "\|"
if ($Parts.Count -lt 6) { continue }
# Skip Linux pseudo-filesystems
$DiskLetter = $Parts[0].Trim()
if ($OSType -ne "Windows" -and $DiskLetter -match "^(tmpfs|devtmpfs|udev|none|overlay|shm)") { continue }
$Rows += [PSCustomObject]@{
SubscriptionName = $SubscriptionName
SubscriptionId = $SubscriptionId
ResourceGroup = $ResourceGroup
VMName = $VMName
OSType = $OSType
VMSize = $VMSize
Location = $Location
DiskLetter = $DiskLetter
DiskLabel = $Parts[1].Trim()
CapacityGB = $Parts[2].Trim()
UsedGB = $Parts[3].Trim()
FreeGB = $Parts[4].Trim()
PercentUsed = $Parts[5].Trim()
}
}
return $Rows
}
# ============================================================
# MAIN
# ============================================================
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Azure VM Disk Usage Report " -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Validate placeholders
$PlaceholderPattern = "^<.+>$"
$ConfigValues = @{
TenantId = $TenantId
ClientId = $ClientId
ClientSecret = $ClientSecret
OutputFolder = $OutputFolder
}
$HasPlaceholders = $false
foreach ($Key in $ConfigValues.Keys) {
if ($ConfigValues[$Key] -match $PlaceholderPattern) {
Write-Warning "Configuration value not set: $Key = $($ConfigValues[$Key])"
$HasPlaceholders = $true
}
}
if ($HasPlaceholders) {
Write-Error "Please replace all <PLACEHOLDER> values before running."
exit 1
}
# Create output folder
if (-not (Test-Path $OutputFolder)) {
New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
Write-Host "Created output folder: $OutputFolder" -ForegroundColor Green
}
else {
Write-Host "Output folder: $OutputFolder" -ForegroundColor Green
}
Write-Host ""
# Authenticate
$TokenObj = Get-BearerToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
Write-Host ""
# Discover subscriptions (use token string for all API calls)
$Subscriptions = Get-AllSubscriptions -Token $TokenObj.AccessToken -ApiVersion $SubscriptionsApiVersion -EnabledOnly $EnabledOnly
Write-Host ""
# Collect results and skipped VMs
$AllDiskRows = [System.Collections.Generic.List[object]]::new()
$SkippedVMs = [System.Collections.Generic.List[object]]::new()
$TotalVMCount = 0
foreach ($Sub in $Subscriptions) {
$SubId = $Sub.subscriptionId
$SubName = $Sub.displayName
Write-Host "----------------------------------------" -ForegroundColor DarkGray
Write-Host "Subscription: $SubName" -ForegroundColor Yellow
Write-Host "----------------------------------------" -ForegroundColor DarkGray
# Refresh token if needed before each subscription
$TokenObj = Get-ValidToken -TokenObj $TokenObj -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
$VMs = Get-VMsInSubscription -Token $TokenObj.AccessToken -SubscriptionId $SubId -ApiVersion $VMListApiVersion -RunningOnly $RunningOnly
if ($VMs.Count -eq 0) {
Write-Host " No $(if ($RunningOnly) { 'running ' })VMs found." -ForegroundColor DarkGray
Write-Host ""
continue
}
Write-Host " Found $($VMs.Count) $(if ($RunningOnly) { 'running ' })VM(s)" -ForegroundColor Cyan
Write-Host ""
foreach ($VM in $VMs) {
$TotalVMCount++
$VMName = $VM.name
$ResourceGroup = $VM.id -split "/" | Select-Object -Index 4
$OSType = $VM.properties.storageProfile.osDisk.osType
$VMSize = $VM.properties.hardwareProfile.vmSize
$Location = $VM.location
Write-Host " [$TotalVMCount] $VMName ($OSType, $VMSize)" -ForegroundColor White
Write-Host " Resource Group : $ResourceGroup" -ForegroundColor DarkGray
Write-Host " Running RunCommand..." -ForegroundColor Gray
# Refresh token if needed before each VM call
$TokenObj = Get-ValidToken -TokenObj $TokenObj -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
$RawOutput = Invoke-VMRunCommand `
-Token $TokenObj.AccessToken `
-SubscriptionId $SubId `
-ResourceGroup $ResourceGroup `
-VMName $VMName `
-OSType $OSType `
-ApiVersion $RunCommandApiVersion `
-TimeoutSeconds $RunCommandTimeoutSeconds `
-PollIntervalSeconds $RunCommandPollIntervalSeconds
if ($null -eq $RawOutput -or $RawOutput.Trim() -eq "") {
Write-Warning " No output received from $VMName — skipping."
$SkippedVMs.Add([PSCustomObject]@{
SubscriptionName = $SubName
SubscriptionId = $SubId
ResourceGroup = $ResourceGroup
VMName = $VMName
OSType = $OSType
Reason = "No output from RunCommand"
})
Write-Host ""
continue
}
$DiskRows = Parse-DiskOutput `
-RawOutput $RawOutput `
-VMName $VMName `
-SubscriptionName $SubName `
-SubscriptionId $SubId `
-ResourceGroup $ResourceGroup `
-OSType $OSType `
-VMSize $VMSize `
-Location $Location
if ($DiskRows.Count -eq 0) {
Write-Warning " Could not parse disk data from $VMName — skipping."
$SkippedVMs.Add([PSCustomObject]@{
SubscriptionName = $SubName
SubscriptionId = $SubId
ResourceGroup = $ResourceGroup
VMName = $VMName
OSType = $OSType
Reason = "Disk output could not be parsed"
})
}
else {
foreach ($Row in $DiskRows) { $AllDiskRows.Add($Row) }
Write-Host " Disks found: $($DiskRows.Count)" -ForegroundColor Green
foreach ($Row in $DiskRows) {
Write-Host " $($Row.DiskLetter) $($Row.CapacityGB)GB total | $($Row.UsedGB)GB used | $($Row.FreeGB)GB free | $($Row.PercentUsed)% used" -ForegroundColor DarkGray
}
}
Write-Host ""
}
}
# ============================================================
# Save output files
# ============================================================
$DateStamp = Get-Date -Format "yyyy-MM-dd"
$OutputFile = Join-Path $OutputFolder "VMDiskUsage_$DateStamp.csv"
$SkipFile = Join-Path $OutputFolder "VMDiskUsage_Skipped_$DateStamp.csv"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Saving results..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
if ($AllDiskRows.Count -gt 0) {
$AllDiskRows | Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8
Write-Host " Disk usage CSV : $OutputFile ($($AllDiskRows.Count) rows across $TotalVMCount VMs)" -ForegroundColor Green
}
else {
Write-Warning " No disk data collected — CSV not written."
}
if ($SkippedVMs.Count -gt 0) {
$SkippedVMs | Export-Csv -Path $SkipFile -NoTypeInformation -Encoding UTF8
Write-Host " Skipped VMs : $SkipFile ($($SkippedVMs.Count) VMs skipped)" -ForegroundColor Yellow
}
else {
Write-Host " No VMs were skipped." -ForegroundColor Green
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Export complete!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""