RVTools est l’outil de référence pour sortir un inventaire complet d’une infrastructure VMware vSphere dans un classeur Excel. Problème : c’est une application Windows .NET, et il n’en existe aucune version native pour macOS. Plutôt que de lancer une VM Windows juste pour ça, on peut obtenir exactement le même résultat — un rapport multi-onglets (VM, CPU, mémoire, disques, réseau, snapshots, hôtes, datastores, santé…) — directement depuis un Mac, avec PowerShell 7 (PowerShell Core) et le module VMware PowerCLI.

Dans cet article : tous les prérequis pas à pas, le script complet (réutilisable sur n’importe quel vCenter, avec saisie interactive ou fichier d’identifiants), et le piège macOS à connaître sur la mise en forme Excel.

Le principe

Le script se connecte à n’importe quel vCenter (ou hôte ESXi), collecte l’inventaire via PowerCLI, puis génère :

  • un seul fichier .xlsx multi-onglets si le module ImportExcel est présent ;
  • sinon, un dossier de fichiers .csv (un par onglet).

À chaque exécution, le rapport est rangé dans un sous-dossier nommé d’après le vCenter, avec un horodatage (aucun écrasement entre deux exports).

Prérequis

1. Homebrew

Le gestionnaire de paquets macOS, si vous ne l’avez pas déjà :

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

2. PowerShell 7 (PowerShell Core)

Attention : le cask powershell stable a été retiré de Homebrew. On passe désormais par la formule :

brew install powershell

# Variantes possibles :
brew install --cask powershell@preview        # version preview
# ou telecharger le .pkg officiel (Apple Silicon / Intel) sur :
#   github.com/PowerShell/PowerShell/releases

On vérifie :

pwsh --version        # doit afficher PowerShell 7.x

3. VMware PowerCLI

Le module officiel VMware, à installer pour l’utilisateur courant (le script propose aussi de le faire automatiquement s’il manque) :

pwsh
Install-Module VMware.PowerCLI -Scope CurrentUser -Force

4. ImportExcel (optionnel mais recommandé)

Pour obtenir un vrai classeur .xlsx multi-onglets. Sans lui, le script bascule automatiquement sur un export CSV :

Install-Module ImportExcel -Scope CurrentUser -Force

5. Un compte vCenter

Un compte ayant au minimum un accès en lecture seule sur le vCenter ou l’hôte ESXi suffit (rôle Read-only). Inutile d’utiliser un compte administrateur.

6. Bonus : couper le message CEIP

PowerCLI affiche un long avertissement au démarrage. Pour le supprimer une fois pour toutes, dans pwsh :

Set-PowerCLIConfiguration -Scope User -ParticipateInCEIP $false -Confirm:$false

Le script

Enregistrez ce qui suit dans un fichier Export-vSphereInventory.ps1. Par défaut, les rapports sont écrits dans ~/VSphere-Reports/ (modifiable via le paramètre -OutputPath).

[CmdletBinding()]
param(
    [string]$Server,
    [string]$CredentialFile,
    [string]$OutputPath = (Join-Path $HOME 'VSphere-Reports'),
    [ValidateSet('auto','xlsx','csv')]
    [string]$Format = 'auto',
    [int]$SnapshotAgeDays = 7,
    [int]$DatastoreFullPct = 90
)

$ErrorActionPreference = 'Stop'

function Write-Step { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan }
function Write-Ok   { param([string]$Msg) Write-Host "    $Msg" -ForegroundColor Green }
function Write-Warn2{ param([string]$Msg) Write-Host "    $Msg" -ForegroundColor Yellow }

# --------------------------------------------------------------------------
# 1. Prerequis : module PowerCLI
# --------------------------------------------------------------------------
Write-Step "Verification de VMware PowerCLI"
if (-not (Get-Module -ListAvailable -Name VMware.PowerCLI)) {
    Write-Warn2 "Module VMware.PowerCLI introuvable."
    $rep = Read-Host "    Installer VMware.PowerCLI pour l'utilisateur courant maintenant ? (o/N)"
    if ($rep -match '^[oOyY]') {
        Install-Module VMware.PowerCLI -Scope CurrentUser -Force -AllowClobber
    } else {
        throw "VMware.PowerCLI est requis. Installe-le avec : Install-Module VMware.PowerCLI -Scope CurrentUser"
    }
}
Import-Module VMware.VimAutomation.Core -ErrorAction Stop
Write-Ok "PowerCLI charge."

Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -ParticipateInCEIP $false -Scope Session -Confirm:$false | Out-Null

# --------------------------------------------------------------------------
# 2. vCenter
# --------------------------------------------------------------------------
if ([string]::IsNullOrWhiteSpace($Server)) {
    $Server = Read-Host "Adresse du vCenter / ESXi (FQDN ou IP)"
}
if ([string]::IsNullOrWhiteSpace($Server)) { throw "Aucun serveur fourni." }

# --------------------------------------------------------------------------
# 2bis. Identifiants : fichier (-CredentialFile) ou interactif
# --------------------------------------------------------------------------
function Get-CredentialFromFile {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) {
        Write-Warn2 "Fichier d'identifiants introuvable : $Path"
        return $null
    }
    $ext = [System.IO.Path]::GetExtension($Path).ToLower()
    try {
        if ($ext -in '.xml','.clixml') {
            $c = Import-Clixml -LiteralPath $Path
            if ($c -is [System.Management.Automation.PSCredential]) { return $c }
            throw "Le fichier XML ne contient pas un PSCredential."
        }
        $raw = Get-Content -LiteralPath $Path -Raw
        if ($ext -eq '.json' -or $raw.TrimStart().StartsWith('{')) {
            $j = $raw | ConvertFrom-Json
            $u = $j.username; if (-not $u) { $u = $j.user }; if (-not $u) { $u = $j.login }
            $p = $j.password; if (-not $p) { $p = $j.pass }
            if (-not $u -or -not $p) { throw "JSON sans username/password." }
            return [pscredential]::new($u, (ConvertTo-SecureString $p -AsPlainText -Force))
        }
        # Format texte : key=value OU 2 lignes
        $lines = Get-Content -LiteralPath $Path | Where-Object { $_ -and -not $_.TrimStart().StartsWith('#') }
        $u = $null; $p = $null
        foreach ($l in $lines) {
            if ($l -match '^\s*(username|user|login)\s*=\s*(.+)$') { $u = $matches[2].Trim() }
            elseif ($l -match '^\s*(password|pass)\s*=\s*(.+)$')   { $p = $matches[2].Trim() }
        }
        if (-not $u -and $lines.Count -ge 2) { $u = $lines[0].Trim(); $p = $lines[1].Trim() }
        if (-not $u -or -not $p) { throw "Format texte non reconnu (attendu 2 lignes ou username=/password=)." }
        return [pscredential]::new($u, (ConvertTo-SecureString $p -AsPlainText -Force))
    } catch {
        Write-Warn2 "Lecture du fichier d'identifiants impossible : $($_.Exception.Message)"
        return $null
    }
}

$Credential = $null
if ($CredentialFile) {
    $Credential = Get-CredentialFromFile -Path $CredentialFile
    if ($Credential) { Write-Ok "Identifiants charges depuis $CredentialFile (user: $($Credential.UserName))." }
}
if (-not $Credential) {
    Write-Step "Identifiants pour $Server"
    $Credential = Get-Credential -Message "Login vSphere pour $Server (ex: administrator@vsphere.local)"
}

# --------------------------------------------------------------------------
# 3. Connexion
# --------------------------------------------------------------------------
Write-Step "Connexion a $Server"
$vc = Connect-VIServer -Server $Server -Credential $Credential
Write-Ok "Connecte a $($vc.Name) (vSphere $($vc.Version) build $($vc.Build))"

function To-GB { param($bytes) if ($null -eq $bytes) { $null } else { [math]::Round($bytes/1GB,2) } }

$report = [ordered]@{}

try {
    Write-Step "Collecte des donnees (cela peut prendre un moment)"
    $allVMs   = Get-VM
    $allHosts = Get-VMHost
    Write-Ok "$($allVMs.Count) VM(s), $($allHosts.Count) hote(s)."

    Write-Step "Onglet vInfo"
    $report['vInfo'] = foreach ($vm in $allVMs) {
        $v = $vm.ExtensionData
        [pscustomobject][ordered]@{
            VM='?'; Powerstate=$vm.PowerState; Template=$v.Config.Template
            CPUs=$vm.NumCpu; 'Memory (MB)'=$vm.MemoryMB
            NICs=($v.Network).Count; Disks=(Get-HardDisk -VM $vm).Count
            'Provisioned (GB)'=[math]::Round($vm.ProvisionedSpaceGB,2)
            'Used (GB)'=[math]::Round($vm.UsedSpaceGB,2)
            'HW version'=$vm.HardwareVersion; 'Guest OS'=$v.Guest.GuestFullName
            'OS (config)'=$v.Config.GuestFullName; 'IP Address'=$v.Guest.IpAddress
            DNSName=$v.Guest.HostName; VMToolsStatus=$v.Guest.ToolsStatus
            Folder=$vm.Folder.Name; ResourcePool=$vm.ResourcePool.Name
            Host=$vm.VMHost.Name; Cluster=(Get-Cluster -VM $vm -ErrorAction SilentlyContinue).Name
            Annotation=$v.Config.Annotation; UUID=$v.Config.Uuid; VMID=$vm.Id
        } | ForEach-Object { $_.VM = $vm.Name; $_ }
    }

    Write-Step "Onglet vCPU"
    $report['vCPU'] = foreach ($vm in $allVMs) {
        [pscustomobject][ordered]@{
            VM=$vm.Name; Powerstate=$vm.PowerState; CPUs=$vm.NumCpu; CoresPerSocket=$vm.CoresPerSocket
            Sockets=$(if ($vm.CoresPerSocket){[math]::Ceiling($vm.NumCpu/$vm.CoresPerSocket)}else{$null})
            'Reservation (MHz)'=$vm.ExtensionData.Config.CpuAllocation.Reservation
            'Limit (MHz)'=$vm.ExtensionData.Config.CpuAllocation.Limit
            Shares=$vm.ExtensionData.Config.CpuAllocation.Shares.Shares
            HotAdd=$vm.ExtensionData.Config.CpuHotAddEnabled; Host=$vm.VMHost.Name
        }
    }

    Write-Step "Onglet vMemory"
    $report['vMemory'] = foreach ($vm in $allVMs) {
        [pscustomobject][ordered]@{
            VM=$vm.Name; Powerstate=$vm.PowerState; 'Size (MB)'=$vm.MemoryMB
            'Reservation (MB)'=$vm.ExtensionData.Config.MemoryAllocation.Reservation
            'Limit (MB)'=$vm.ExtensionData.Config.MemoryAllocation.Limit
            Shares=$vm.ExtensionData.Config.MemoryAllocation.Shares.Shares
            HotAdd=$vm.ExtensionData.Config.MemoryHotAddEnabled; Host=$vm.VMHost.Name
        }
    }

    Write-Step "Onglet vDisk"
    $report['vDisk'] = foreach ($vm in $allVMs) {
        foreach ($hd in (Get-HardDisk -VM $vm)) {
            [pscustomobject][ordered]@{
                VM=$vm.Name; Powerstate=$vm.PowerState; Disk=$hd.Name
                'Capacity (GB)'=[math]::Round($hd.CapacityGB,2); DiskType=$hd.StorageFormat
                Persistence=$hd.Persistence; Datastore=($hd.Filename -split '\]')[0].TrimStart('['); Path=$hd.Filename
            }
        }
    }

    Write-Step "Onglet vPartition"
    $report['vPartition'] = foreach ($vm in $allVMs) {
        foreach ($d in $vm.ExtensionData.Guest.Disk) {
            [pscustomobject][ordered]@{
                VM=$vm.Name; Powerstate=$vm.PowerState; DiskPath=$d.DiskPath
                'Capacity (GB)'=(To-GB $d.Capacity); 'Free (GB)'=(To-GB $d.FreeSpace)
                'Free %'=$(if ($d.Capacity -gt 0){[math]::Round(($d.FreeSpace/$d.Capacity)*100,1)}else{$null})
            }
        }
    }

    Write-Step "Onglet vNetwork"
    $report['vNetwork'] = foreach ($vm in $allVMs) {
        foreach ($nic in (Get-NetworkAdapter -VM $vm)) {
            [pscustomobject][ordered]@{
                VM=$vm.Name; Powerstate=$vm.PowerState; Adapter=$nic.Name; Type=$nic.Type
                MacAddress=$nic.MacAddress; Network=$nic.NetworkName
                Connected=$nic.ConnectionState.Connected; StartConnected=$nic.ConnectionState.StartConnected; Host=$vm.VMHost.Name
            }
        }
    }

    Write-Step "Onglet vSnapshot"
    $report['vSnapshot'] = foreach ($vm in $allVMs) {
        foreach ($snap in (Get-Snapshot -VM $vm -ErrorAction SilentlyContinue)) {
            [pscustomobject][ordered]@{
                VM=$vm.Name; Snapshot=$snap.Name; Description=$snap.Description; Created=$snap.Created
                'Age (days)'=[math]::Round(((Get-Date)-$snap.Created).TotalDays,1)
                'Size (GB)'=[math]::Round($snap.SizeGB,2); PowerState=$snap.PowerState; IsCurrent=$snap.IsCurrent
            }
        }
    }

    Write-Step "Onglet vTools"
    $report['vTools'] = foreach ($vm in $allVMs) {
        $g = $vm.ExtensionData.Guest
        [pscustomobject][ordered]@{
            VM=$vm.Name; Powerstate=$vm.PowerState; ToolsStatus=$g.ToolsStatus; ToolsVersion=$g.ToolsVersion
            ToolsVersionStatus=$g.ToolsVersionStatus2; ToolsRunningStatus=$g.ToolsRunningStatus
            UpgradePolicy=$vm.ExtensionData.Config.Tools.ToolsUpgradePolicy; Host=$vm.VMHost.Name
        }
    }

    Write-Step "Onglet vHost"
    $report['vHost'] = foreach ($h in $allHosts) {
        $hv = $h.ExtensionData
        [pscustomobject][ordered]@{
            Host=$h.Name; Cluster=(Get-Cluster -VMHost $h -ErrorAction SilentlyContinue).Name
            ConnectionState=$h.ConnectionState; PowerState=$h.PowerState
            Vendor=$hv.Hardware.SystemInfo.Vendor; Model=$hv.Hardware.SystemInfo.Model
            'CPU Model'=$hv.Summary.Hardware.CpuModel; CPUSockets=$hv.Hardware.CpuInfo.NumCpuPackages
            CPUCores=$hv.Hardware.CpuInfo.NumCpuCores; CPUThreads=$hv.Hardware.CpuInfo.NumCpuThreads
            'CPU MHz'=$h.CpuTotalMhz; 'CPU Used MHz'=$h.CpuUsageMhz
            'Memory (GB)'=[math]::Round($h.MemoryTotalGB,1); 'Mem Used (GB)'=[math]::Round($h.MemoryUsageGB,1)
            ESXiVersion=$h.Version; Build=$h.Build; 'BIOS Version'=$hv.Hardware.BiosInfo.BiosVersion
            'Service Tag'=($hv.Hardware.SystemInfo.OtherIdentifyingInfo | Where-Object { $_.IdentifierType.Key -eq 'ServiceTag' }).IdentifierValue
            NumVMs=(Get-VM -Location $h -ErrorAction SilentlyContinue).Count
        }
    }

    Write-Step "Onglet vCluster"
    $report['vCluster'] = foreach ($c in (Get-Cluster)) {
        [pscustomobject][ordered]@{
            Cluster=$c.Name; HAEnabled=$c.HAEnabled; DrsEnabled=$c.DrsEnabled; DrsAutomation=$c.DrsAutomationLevel
            Hosts=($c | Get-VMHost).Count; VMs=($c | Get-VM).Count
            'Total CPU (GHz)'=[math]::Round((($c | Get-VMHost | Measure-Object CpuTotalMhz -Sum).Sum)/1000,1)
            'Total Mem (GB)'=[math]::Round((($c | Get-VMHost | Measure-Object MemoryTotalGB -Sum).Sum),1)
        }
    }

    Write-Step "Onglet vDatastore"
    $report['vDatastore'] = foreach ($ds in (Get-Datastore)) {
        [pscustomobject][ordered]@{
            Datastore=$ds.Name; Type=$ds.Type; 'Capacity (GB)'=[math]::Round($ds.CapacityGB,1)
            'Free (GB)'=[math]::Round($ds.FreeSpaceGB,1)
            'Free %'=$(if ($ds.CapacityGB -gt 0){[math]::Round(($ds.FreeSpaceGB/$ds.CapacityGB)*100,1)}else{$null})
            'Used (GB)'=[math]::Round($ds.CapacityGB-$ds.FreeSpaceGB,1)
            NumVMs=($ds | Get-VM -ErrorAction SilentlyContinue).Count; Accessible=$ds.State
        }
    }

    Write-Step "Onglet vRP (Resource Pools)"
    $report['vRP'] = foreach ($rp in (Get-ResourcePool)) {
        [pscustomobject][ordered]@{
            ResourcePool=$rp.Name; 'CPU Reservation (MHz)'=$rp.CpuReservationMHz; 'CPU Limit (MHz)'=$rp.CpuLimitMHz
            CPUSharesLevel=$rp.CpuSharesLevel; 'Mem Reservation (MB)'=$rp.MemReservationMB
            'Mem Limit (MB)'=$rp.MemLimitMB; MemSharesLevel=$rp.MemSharesLevel
            NumVMs=($rp | Get-VM -ErrorAction SilentlyContinue).Count
        }
    }

    # ----------------------------------------------------------------------
    # vHealth : detection de problemes (facon onglet vHealth de RVTools)
    # ----------------------------------------------------------------------
    Write-Step "Onglet vHealth"
    $health = New-Object System.Collections.Generic.List[object]
    function Add-Health {
        param([string]$Severity,[string]$Category,[string]$Object,[string]$Issue,$Detail)
        $health.Add([pscustomobject][ordered]@{
            Severity = $Severity; Category = $Category; Object = $Object; Issue = $Issue; Detail = $Detail
        })
    }

    # -- Snapshots trop anciens
    foreach ($vm in $allVMs) {
        foreach ($snap in (Get-Snapshot -VM $vm -ErrorAction SilentlyContinue)) {
            $age = [math]::Round(((Get-Date) - $snap.Created).TotalDays, 1)
            if ($age -ge $SnapshotAgeDays) {
                Add-Health 'Warning' 'Snapshot' $vm.Name "Snapshot ancien (>= $SnapshotAgeDays j)" "'$($snap.Name)' - $age j - $([math]::Round($snap.SizeGB,2)) GB"
            }
        }
    }

    # -- VMware Tools absents / obsoletes (VM allumees)
    foreach ($vm in ($allVMs | Where-Object { $_.PowerState -eq 'PoweredOn' })) {
        $ts = $vm.ExtensionData.Guest.ToolsStatus
        if ($ts -in 'toolsNotInstalled','toolsNotRunning') {
            Add-Health 'Warning' 'VMware Tools' $vm.Name 'Tools absents ou arretes' "$ts"
        } elseif ($ts -eq 'toolsOld') {
            Add-Health 'Info' 'VMware Tools' $vm.Name 'Tools obsoletes' "$ts (v$($vm.ExtensionData.Guest.ToolsVersion))"
        }
    }

    # -- Datastores trop pleins
    foreach ($ds in (Get-Datastore)) {
        if ($ds.CapacityGB -gt 0) {
            $usedPct = [math]::Round((($ds.CapacityGB - $ds.FreeSpaceGB) / $ds.CapacityGB) * 100, 1)
            if ($usedPct -ge $DatastoreFullPct) {
                $sev = if ($usedPct -ge 95) { 'Critical' } else { 'Warning' }
                Add-Health $sev 'Datastore' $ds.Name "Datastore plein (>= $DatastoreFullPct%)" "$usedPct% utilise - $([math]::Round($ds.FreeSpaceGB,1)) GB libres"
            }
        }
    }

    # -- VMs orphelines / inaccessibles
    foreach ($vm in $allVMs) {
        $cs = $vm.ExtensionData.Runtime.ConnectionState
        if ($cs -in 'orphaned','inaccessible','invalid') {
            Add-Health 'Critical' 'VM' $vm.Name 'VM orpheline / inaccessible' "$cs"
        }
    }

    # -- Hotes deconnectes ou en maintenance
    foreach ($h in $allHosts) {
        if ($h.ConnectionState -ne 'Connected') {
            Add-Health 'Critical' 'Host' $h.Name 'Hote non connecte' "$($h.ConnectionState)"
        }
        if ($h.ExtensionData.Runtime.InMaintenanceMode) {
            Add-Health 'Info' 'Host' $h.Name 'Hote en mode maintenance' ''
        }
    }

    # -- CD/ISO ou floppy montes (empechent vMotion)
    foreach ($vm in ($allVMs | Where-Object { $_.PowerState -eq 'PoweredOn' })) {
        foreach ($cd in (Get-CDDrive -VM $vm -ErrorAction SilentlyContinue)) {
            if ($cd.IsoPath -or $cd.HostDevice -or $cd.RemoteDevice) {
                Add-Health 'Info' 'VM' $vm.Name 'Lecteur CD/ISO connecte' "$($cd.IsoPath)$($cd.HostDevice)$($cd.RemoteDevice)"
            }
        }
    }

    # Tri par severite (Critical > Warning > Info)
    $sevOrder = @{ 'Critical'=0; 'Warning'=1; 'Info'=2 }
    $report['vHealth'] = $health | Sort-Object @{ Expression = { $sevOrder[$_.Severity] } }, Category, Object
    $nCrit = ($health | Where-Object Severity -eq 'Critical').Count
    $nWarn = ($health | Where-Object Severity -eq 'Warning').Count
    Write-Ok "$($health.Count) point(s) detecte(s) : $nCrit critique(s), $nWarn avertissement(s)."

    # ----------------------------------------------------------------------
    # Export : sous-dossier nomme d'apres le vCenter
    # ----------------------------------------------------------------------
    $safeName = ($Server -replace '[^\w\.\-]','_')
    $targetDir = Join-Path $OutputPath $safeName
    if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
    $stamp    = Get-Date -Format 'yyyyMMdd-HHmmss'
    $baseName = "RVTools_${safeName}_${stamp}"

    $hasImportExcel = [bool](Get-Module -ListAvailable -Name ImportExcel)
    $useXlsx = switch ($Format) { 'xlsx' {$true} 'csv' {$false} default {$hasImportExcel} }

    if ($useXlsx -and -not $hasImportExcel) {
        Write-Warn2 "Format xlsx demande mais module ImportExcel absent."
        $rep = Read-Host "    Installer ImportExcel maintenant ? (o/N)"
        if ($rep -match '^[oOyY]') { Install-Module ImportExcel -Scope CurrentUser -Force; $hasImportExcel = $true }
        else { Write-Warn2 "Repli sur export CSV."; $useXlsx = $false }
    }

    if ($useXlsx) {
        Import-Module ImportExcel
        $xlsx = Join-Path $targetDir "$baseName.xlsx"
        Write-Step "Ecriture du classeur Excel : $xlsx"
        # NB : pas de -AutoSize (non supporte sous macOS/.NET 6+, meme avec mono-libgdiplus).
        # On accumule tous les onglets dans un seul package puis on regle les largeurs
        # manuellement (longueur max du contenu) -> aucun warning, aucune dependance.
        if (Test-Path $xlsx) { Remove-Item $xlsx -Force }
        $excel = $null
        foreach ($sheet in $report.Keys) {
            $data = $report[$sheet]; if (-not $data) { $data = [pscustomobject]@{ Info='Aucune donnee' } }
            if ($excel) {
                $excel = $data | Export-Excel -ExcelPackage $excel -WorksheetName $sheet -FreezeTopRow -BoldTopRow -TableName $sheet -PassThru -WarningAction SilentlyContinue
            } else {
                $excel = $data | Export-Excel -Path $xlsx -WorksheetName $sheet -FreezeTopRow -BoldTopRow -TableName $sheet -PassThru -WarningAction SilentlyContinue
            }
        }
        # Largeur des colonnes calculee a la main (sans System.Drawing)
        foreach ($ws in $excel.Workbook.Worksheets) {
            if ($null -eq $ws.Dimension) { continue }
            for ($col = 1; $col -le $ws.Dimension.End.Column; $col++) {
                $maxLen = 0
                for ($row = 1; $row -le $ws.Dimension.End.Row; $row++) {
                    $t = [string]$ws.Cells[$row, $col].Text
                    if ($t.Length -gt $maxLen) { $maxLen = $t.Length }
                }
                $ws.Column($col).Width = [math]::Min([math]::Max($maxLen + 2, 8), 60)
            }
        }
        Close-ExcelPackage $excel
        Write-Ok "Termine : $xlsx"; $result = $xlsx
    } else {
        $dir = Join-Path $targetDir $baseName
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        Write-Step "Ecriture des CSV dans : $dir"
        foreach ($sheet in $report.Keys) {
            $csv = Join-Path $dir "$sheet.csv"; $data = $report[$sheet]
            if ($data) { $data | Export-Csv -Path $csv -NoTypeInformation -Encoding UTF8 } else { 'Aucune donnee' | Out-File -FilePath $csv -Encoding UTF8 }
            Write-Ok "  - $sheet.csv"
        }
        Write-Ok "Termine : $dir"; $result = $dir
    }

    Write-Host ""
    Write-Host "RAPPORT GENERE : $result" -ForegroundColor Green
}
finally {
    if ($vc) { Disconnect-VIServer -Server $Server -Confirm:$false -ErrorAction SilentlyContinue; Write-Ok "Deconnecte de $Server." }
}

Utilisation

Le script ne contient rien en dur : on lui passe le vCenter en paramètre (ou il le demande), et les identifiants sont soit demandés interactivement (mot de passe masqué), soit lus dans un fichier.

# 100% interactif : demande le vCenter puis le login / mot de passe
pwsh ./Export-vSphereInventory.ps1

# En precisant le vCenter (login / pass demandes)
pwsh ./Export-vSphereInventory.ps1 -Server VCENTER.MONDOMAINE.LAN

# Avec fichier d'identifiants, format force et seuils de sante personnalises
pwsh ./Export-vSphereInventory.ps1 -Server 10.0.0.10 -CredentialFile ~/.vc-creds.json -Format xlsx -SnapshotAgeDays 3 -DatastoreFullPct 85

Le fichier d’identifiants

Le paramètre -CredentialFile accepte plusieurs formats. Le plus simple, un JSON :

# ~/.vc-creds.json
{ "username": "administrator@vsphere.local", "password": "MotDePasse" }

# A proteger imperativement :
chmod 600 ~/.vc-creds.json

Sont aussi acceptés : un fichier texte de 2 lignes (login puis mot de passe), des lignes username= / password=, ou — le plus sûr — un credential PowerShell chiffré (lié au compte et à la machine) :

# Variante chiffree (recommandee)
pwsh -c "Get-Credential | Export-Clixml ~/.vc-creds.xml"
pwsh ./Export-vSphereInventory.ps1 -Server 10.0.0.10 -CredentialFile ~/.vc-creds.xml

Le piège macOS : la mise en forme Excel

C’est LE point qui fait perdre du temps. Le module ImportExcel propose une option -AutoSize pour ajuster la largeur des colonnes… mais elle s’appuie sur System.Drawing, qui n’est plus supporté hors Windows depuis .NET 6+ (base de PowerShell 7). Résultat : même après brew install mono-libgdiplus, on obtient en boucle :

WARNING: ImportExcel Module Cannot Autosize. Please run the following command to install dependencies:
brew install mono-libgdiplus

La solution propre, intégrée dans le script ci-dessus : ne pas utiliser -AutoSize et calculer la largeur des colonnes soi-même (longueur maximale du contenu), puis museler l’avertissement résiduel avec -WarningAction SilentlyContinue. Aucune dépendance graphique, aucun warning, et des colonnes correctement dimensionnées.

Ce que contient le rapport

Le classeur reprend la logique de RVTools, avec un onglet par thème :

  • vInfo, vCPU, vMemory, vDisk, vPartition, vNetwork : les machines virtuelles sous tous les angles ;
  • vSnapshot, vTools : snapshots et état des VMware Tools ;
  • vHost, vCluster, vDatastore, vRP : hôtes, clusters, datastores et resource pools ;
  • vHealth : un onglet de détection de problèmes (snapshots trop anciens, VMware Tools absents ou obsolètes, datastores trop pleins, VM orphelines, hôtes déconnectés ou en maintenance, lecteurs CD/ISO montés), trié par sévérité. Les seuils sont réglables via -SnapshotAgeDays et -DatastoreFullPct.

Récapitulatif

# Installation (une seule fois)
brew install powershell
pwsh
Install-Module VMware.PowerCLI -Scope CurrentUser -Force
Install-Module ImportExcel    -Scope CurrentUser -Force
Set-PowerCLIConfiguration -Scope User -ParticipateInCEIP $false -Confirm:$false
exit

# Generation d'un rapport
pwsh ./Export-vSphereInventory.ps1 -Server VCENTER.MONDOMAINE.LAN

Conclusion

Pas besoin de Windows ni de machine virtuelle : avec PowerShell Core et PowerCLI, on obtient sur macOS un inventaire vSphere complet, réutilisable sur n’importe quel vCenter, exporté dans un classeur Excel propre — le tout dans un script que l’on peut aussi planifier ou intégrer à ses propres workflows. Un équivalent RVTools tout à fait honorable, et 100% scriptable.