← Back to Scripts

Azure VM Disk Usage Report

IntermediatePowerShell 7+Azure

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:

ColumnDescription
SubscriptionNameDisplay name of the Azure subscription
SubscriptionIdGUID of the subscription
ResourceGroupResource group containing the VM
VMNameName of the virtual machine
OSTypeWindows or Linux
VMSizeAzure VM size (e.g. Standard_D2s_v3)
LocationAzure region
DiskLetterDrive letter (Windows) or mount point (Linux)
DiskLabelVolume label if available
CapacityGBTotal disk capacity in GB
UsedGBUsed space in GB
FreeGBFree space in GB
PercentUsedPercentage 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-PSDrive on Windows and df on 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:

VariableDescriptionExample
$TenantIdYour Azure AD / Entra ID tenant IDxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
$ClientIdApp registration client IDxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
$ClientSecretApp registration secret valueyour-secret-value
$OutputFolderLocal folder path for CSV outputC:\DiskReports

Optional Settings

VariableDefaultDescription
$EnabledOnly$trueOnly query VMs in Enabled subscriptions
$RunningOnly$trueOnly query running VMs (recommended)
$RunCommandTimeoutSeconds120Max seconds to wait per VM RunCommand
$RunCommandPollIntervalSeconds5Polling 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 df command while Windows VMs use Get-PSDrive / WMI.

The Script

Get-AzureVMDiskUsage.ps1
#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 ""