Guide technique ANYSYS · vérifié le 26 octobre 2026

Recette post-migration Windows : relevé avant/après en PowerShell

Modèle d'essai de la machine analytique de Charles Babbage
Modèle d'essai de la machine analytique de Charles BabbageDaderot, Wikimedia Commons, CC0 · Crédits

Une VM qui démarre n’est pas une VM migrée. Ce guide fournit deux scripts testés : l’un capture l’état technique d’un serveur Windows avant la bascule, l’autre compare ce relevé avec l’état après la bascule et rend un code retour exploitable dans un runbook, quel que soit l’hyperviseur cible.

01 / Principe

Principe

La validation technique doit être objective et répétable : mêmes contrôles sur chaque VM, même résultat attendu, trace écrite. Le relevé de référence se prend dans l’invité juste avant la bascule (après la désinstallation éventuelle des outils VMware, ou juste avant si elle se fait pendant la fenêtre), puis une seconde fois après le démarrage sur la cible.

DomaineCe qui est comparéPourquoi
RéseauAdresses IPv4, passerelle, DNSLe changement de carte virtuelle fait perdre ou dupliquer la configuration
Cartes fantômesCartes non présentesElles retiennent l’ancienne adresse statique
StockageDisques hors ligne ou en lecture seule, lettres de lecteurStratégie SAN et changement de contrôleur
ServicesServices automatiques qui tournaient avantDépendances matérielles ou réseau cassées
PortsPorts en écoute (hors boucle locale)Applications démarrées mais sans écoute
IdentitéCanal sécurisé du domaine, activation WindowsChangement de matériel virtuel et horloge
JournauxNouvelles sources d’erreurs système et applicationSignal faible sur les problèmes non couverts

02 / Capture

Capturer l’état de référence

Get-MigrationBaseline.ps1PowerShell · 113 lignesTélécharger
<#
.SYNOPSIS
    Captures a technical baseline of a Windows server, before and after a migration.

.DESCRIPTION
    Run it inside the guest once before the cutover and once after it, then compare the
    two JSON files with Compare-MigrationBaseline.ps1. The capture is read-only.

    Collected: platform (manufacturer/model), OS, network configuration (IP, gateway, DNS,
    MAC), ghost (non-present) network adapters, disks and volumes, listening ports,
    automatic services, installed virtualization tools, secure channel, activation, time
    source and recent System/Application errors.

.PARAMETER Path
    Output JSON file. Defaults to .\baseline-<computer>-<date>.json

.EXAMPLE
    .\Get-MigrationBaseline.ps1 -Path C:\Migration\before.json

.NOTES
    Windows Server 2012 R2 or later, Windows PowerShell 5.1. Run as administrator.
    Source: ANYSYS ICT, https://www.anysys.be/articles/
#>
[CmdletBinding()]
param(
    [string]$Path = (Join-Path (Get-Location) ("baseline-{0}-{1:yyyyMMdd-HHmm}.json" -f $env:COMPUTERNAME, (Get-Date)))
)

$ErrorActionPreference = 'Continue'

$cs  = Get-CimInstance Win32_ComputerSystem
$os  = Get-CimInstance Win32_OperatingSystem
$cfg = Get-NetIPConfiguration -Detailed

$network = foreach ($c in $cfg) {
    [pscustomobject]@{
        Alias     = $c.InterfaceAlias
        Mac       = $c.NetAdapter.MacAddress
        Dhcp      = [string]$c.NetIPv4Interface.Dhcp
        IPv4      = @($c.IPv4Address | ForEach-Object { '{0}/{1}' -f $_.IPAddress, $_.PrefixLength })
        Gateway   = @($c.IPv4DefaultGateway.NextHop)
        Dns       = @($c.DNSServer | Where-Object AddressFamily -eq 2 | ForEach-Object ServerAddresses)
        Profile   = [string]$c.NetProfile.NetworkCategory
    }
}

# Non-present adapters keep their static IP in the registry and block re-use of that IP.
$ghostNics = @(Get-PnpDevice -Class Net -ErrorAction SilentlyContinue |
    Where-Object Status -eq 'Unknown' |
    Select-Object FriendlyName, InstanceId)

$disks = @(Get-Disk | Select-Object Number, FriendlyName, PartitionStyle, IsOffline, IsReadOnly,
    @{ n = 'SizeGB'; e = { [math]::Round($_.Size / 1GB, 1) } })

$volumes = @(Get-Volume | Where-Object DriveLetter | Sort-Object DriveLetter |
    Select-Object DriveLetter, FileSystemLabel, FileSystem, HealthStatus,
    @{ n = 'SizeGB'; e = { [math]::Round($_.Size / 1GB, 1) } },
    @{ n = 'FreeGB'; e = { [math]::Round($_.SizeRemaining / 1GB, 1) } })

$listening = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
    Where-Object { $_.LocalAddress -notin '127.0.0.1', '::1' } |
    ForEach-Object {
        $p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
        '{0}/{1}' -f $_.LocalPort, $p.ProcessName
    } | Sort-Object -Unique)

$autoServices = @(Get-CimInstance Win32_Service -Filter "StartMode='Auto'" |
    Select-Object Name, State | Sort-Object Name)

$uninstallKeys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
$virtTools = @(Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue |
    Where-Object { $_.DisplayName -match 'VMware Tools|Nutanix|VirtIO|QEMU|Hyper-V' } |
    Select-Object DisplayName, DisplayVersion, PSChildName)

$secureChannel = $null
if ($cs.PartOfDomain) {
    try   { $secureChannel = Test-ComputerSecureChannel -ErrorAction Stop }
    catch { $secureChannel = "error: $($_.Exception.Message)" }
}

$activation = @(Get-CimInstance SoftwareLicensingProduct -Filter "PartialProductKey IS NOT NULL AND ApplicationID='55c92734-d682-4d71-983e-d6ec3f16059f'" |
    Select-Object Name, LicenseStatus)

$since  = (Get-Date).AddHours(-24)
$errors = foreach ($log in 'System', 'Application') {
    Get-WinEvent -FilterHashtable @{ LogName = $log; Level = 1, 2; StartTime = $since } -ErrorAction SilentlyContinue |
        Group-Object ProviderName, Id |
        ForEach-Object { [pscustomobject]@{ Log = $log; Source = $_.Group[0].ProviderName; Id = $_.Group[0].Id; Count = $_.Count } }
}

[pscustomobject]@{
    CapturedAt      = (Get-Date).ToString('s')
    ComputerName    = $env:COMPUTERNAME
    Manufacturer    = $cs.Manufacturer
    Model           = $cs.Model
    OS              = '{0} ({1})' -f $os.Caption, $os.Version
    LastBoot        = $os.LastBootUpTime.ToString('s')
    Domain          = $cs.Domain
    SecureChannel   = $secureChannel
    TimeSource      = (& w32tm.exe /query /source | Out-String).Trim()
    Network         = @($network)
    GhostNics       = $ghostNics
    Disks           = $disks
    Volumes         = $volumes
    ListeningPorts  = $listening
    AutoServices    = $autoServices
    VirtTools       = $virtTools
    Activation      = $activation
    RecentErrors    = @($errors)
} | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding UTF8

Write-Host "Baseline written to $Path"
PowerShell
# Avant la bascule, dans l'invité, en administrateur
New-Item -ItemType Directory -Path C:\Migration -Force | Out-Null
.\Get-MigrationBaseline.ps1 -Path C:\Migration\before.json

Le fichier JSON reste dans le disque de la VM et voyage donc avec elle. Copiez-le aussi hors de la VM : en cas de retour arrière, il documente l’état d’origine.

03 / Comparaison

Comparer après la bascule

Compare-MigrationBaseline.ps1PowerShell · 97 lignesTélécharger
<#
.SYNOPSIS
    Compares two baselines taken by Get-MigrationBaseline.ps1 (before / after a migration).

.DESCRIPTION
    Prints what changed in the areas that usually break after a hypervisor change:
    IP configuration, offline or read-only disks, missing volumes, ports no longer
    listening, automatic services no longer running, ghost adapters, secure channel,
    activation and new error sources. Exit code 1 when at least one check fails, so the
    script can gate a runbook.

.EXAMPLE
    .\Compare-MigrationBaseline.ps1 -Before .\before.json -After .\after.json

.NOTES
    Source: ANYSYS ICT, https://www.anysys.be/articles/
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$Before,
    [Parameter(Mandatory)][string]$After
)

$b = Get-Content -Path $Before -Raw | ConvertFrom-Json
$a = Get-Content -Path $After  -Raw | ConvertFrom-Json
foreach ($f in @{ Before = $b; After = $a }.GetEnumerator()) {
    if (-not $f.Value.CapturedAt) { throw "$($f.Key) is not a file produced by Get-MigrationBaseline.ps1" }
}
$failed = 0

function Test-SameSet {
    # Compare-Object refuses empty input, so empty sets are handled explicitly.
    param($Reference, $Difference)
    $r = @($Reference | Where-Object { $_ }); $d = @($Difference | Where-Object { $_ })
    if (-not $r.Count -and -not $d.Count) { return $true }
    if (-not $r.Count -or -not $d.Count)  { return $false }
    return -not (Compare-Object $r $d)
}

function Write-Check {
    param([string]$Name, [bool]$Ok, [string]$Detail = '')
    if ($Ok) { Write-Host ("[ OK ] {0}" -f $Name) -ForegroundColor Green }
    else     { Write-Host ("[FAIL] {0} {1}" -f $Name, $Detail) -ForegroundColor Red; $script:failed++ }
}

Write-Host ("Platform: {0} {1}  ->  {2} {3}" -f $b.Manufacturer, $b.Model, $a.Manufacturer, $a.Model)

# Network: compare addressing, not adapter names (they change with the virtual NIC model).
$ipB = @($b.Network | ForEach-Object { $_.IPv4 }) | Sort-Object
$ipA = @($a.Network | ForEach-Object { $_.IPv4 }) | Sort-Object
Write-Check 'IPv4 addresses' (Test-SameSet $ipB $ipA) ("before: {0} / after: {1}" -f ($ipB -join ','), ($ipA -join ','))

$gwB = @($b.Network | ForEach-Object { $_.Gateway }) | Sort-Object -Unique
$gwA = @($a.Network | ForEach-Object { $_.Gateway }) | Sort-Object -Unique
Write-Check 'Default gateway' (Test-SameSet $gwB $gwA) ("before: {0} / after: {1}" -f ($gwB -join ','), ($gwA -join ','))

$dnsB = @($b.Network | ForEach-Object { $_.Dns }) | Sort-Object -Unique
$dnsA = @($a.Network | ForEach-Object { $_.Dns }) | Sort-Object -Unique
Write-Check 'DNS servers' (Test-SameSet $dnsB $dnsA) ("before: {0} / after: {1}" -f ($dnsB -join ','), ($dnsA -join ','))

$ghost = @($a.GhostNics | Where-Object { $_ })
Write-Check 'No ghost network adapter' ($ghost.Count -eq 0) ("{0} non-present adapter(s): {1}" -f $ghost.Count, (($ghost.FriendlyName) -join ', '))

# Storage
$badDisks = @($a.Disks | Where-Object { $_ -and ($_.IsOffline -or $_.IsReadOnly) })
Write-Check 'All disks online and writable' ($badDisks.Count -eq 0) ("disk(s): {0}" -f (($badDisks.Number) -join ','))

$missingVol = @($b.Volumes.DriveLetter | Where-Object { $_ -notin $a.Volumes.DriveLetter })
Write-Check 'All drive letters present' ($missingVol.Count -eq 0) ("missing: {0}" -f ($missingVol -join ','))

# Services and ports
$runningB = @($b.AutoServices | Where-Object State -eq 'Running' | ForEach-Object Name)
$runningA = @($a.AutoServices | Where-Object State -eq 'Running' | ForEach-Object Name)
$stopped  = @($runningB | Where-Object { $_ -notin $runningA -and $_ -notmatch '^VM|^vm3|^VGAuth' })
Write-Check 'Automatic services still running' ($stopped.Count -eq 0) ("not running: {0}" -f ($stopped -join ', '))

$ports = @($b.ListeningPorts | Where-Object { $_ -notin $a.ListeningPorts -and $_ -notmatch '/vm' })
Write-Check 'Listening ports preserved' ($ports.Count -eq 0) ("missing: {0}" -f ($ports -join ', '))

# Identity and licensing
if ($null -ne $b.SecureChannel) {
    Write-Check 'Domain secure channel' ($a.SecureChannel -eq $true) ("value: {0}" -f $a.SecureChannel)
}
$licB = @($b.Activation | Where-Object LicenseStatus -eq 1).Count
$licA = @($a.Activation | Where-Object LicenseStatus -eq 1).Count
Write-Check 'Windows activation' ($licA -ge $licB) ("licensed products before: {0} / after: {1}" -f $licB, $licA)

# Leftovers and new errors are reported, the operator decides.
$vmw = @($a.VirtTools | Where-Object { $_.DisplayName -match 'VMware' })
if ($vmw.Count) { Write-Host ("[INFO] VMware components still installed: {0}" -f (($vmw.DisplayName) -join ', ')) -ForegroundColor Yellow }

$keysB = @($b.RecentErrors | Where-Object { $_ } | ForEach-Object { '{0}/{1}' -f $_.Source, $_.Id })
$newErr = @($a.RecentErrors | Where-Object { $_ -and ('{0}/{1}' -f $_.Source, $_.Id) -notin $keysB })
foreach ($e in $newErr) { Write-Host ("[INFO] New error source: {0} {1} id {2} (x{3})" -f $e.Log, $e.Source, $e.Id, $e.Count) -ForegroundColor Yellow }

if ($failed) { Write-Host "$failed check(s) failed." -ForegroundColor Red; exit 1 }
Write-Host 'All checks passed.' -ForegroundColor Green
PowerShell
.\Get-MigrationBaseline.ps1 -Path C:\Migration\after.json
.\Compare-MigrationBaseline.ps1 -Before C:\Migration\before.json -After C:\Migration\after.json
if ($LASTEXITCODE -ne 0) { Write-Warning 'Recette technique en échec : ne pas libérer la VM.' }

Exemple de sortie sur un relevé après migration présentant les défauts classiques :

Sortie
Platform: VMware, Inc. VMware7,1  ->  Microsoft Corporation Virtual Machine
[FAIL] IPv4 addresses before: 10.0.20.15/24 / after: 169.254.12.4/16
[FAIL] Default gateway before: 10.0.20.1 / after:
[FAIL] DNS servers before: 10.0.20.10,10.0.20.11 / after:
[FAIL] No ghost network adapter 1 non-present adapter(s): vmxnet3 Ethernet Adapter
[FAIL] All disks online and writable disk(s): 1
[FAIL] All drive letters present missing: D
[FAIL] Automatic services still running not running: MSSQLSERVER
[FAIL] Listening ports preserved missing: 1433/sqlservr
[FAIL] Domain secure channel value: False
[ OK ] Windows activation
[INFO] VMware components still installed: VMware Tools
[INFO] New error source: System Service Control Manager id 7000 (x3)
10 check(s) failed.

Les différences attendues ne sont pas signalées : nom de la carte réseau, ordre des serveurs DNS, services et ports propres aux outils VMware.

04 / Corrections

Corriger les écarts courants

ÉchecCause habituelleCorrection
IPv4 addresses (adresse 169.254.x.x)Configuration statique restée sur l’ancienne carteSupprimer la carte fantôme, puis reconfigurer : New-NetIPAddress et Set-DnsClientServerAddress
No ghost network adapterAncienne carte VMXNET3 non présentepnputil /remove-device <InstanceId> (Windows Server 2022 et versions ultérieures), sinon Gestionnaire de périphériques
All disks onlineStratégie SAN OfflineSharedSet-Disk -Number 1 -IsOffline $false, puis Set-StorageSetting -NewDiskPolicy OnlineAll
All drive letters presentDisque hors ligne ou lettre réattribuéeRemettre le disque en ligne, puis Set-Partition -DriveLetter E -NewDriveLetter D si nécessaire
Domain secure channelHorloge ou réseau incorrects au premier démarrageCorriger le réseau d’abord, puis Test-ComputerSecureChannel -Repair -Credential (Get-Credential)
Windows activationChangement de matériel virtuelRéactiver selon le modèle de licence (KMS, AVMA sur Hyper-V Datacenter, clé MAK)
PowerShell
# Reconfiguration IP après suppression de la carte fantôme
$nic = Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object -First 1
New-NetIPAddress -InterfaceIndex $nic.ifIndex -IPAddress 10.0.20.15 -PrefixLength 24 -DefaultGateway 10.0.20.1
Set-DnsClientServerAddress -InterfaceIndex $nic.ifIndex -ServerAddresses 10.0.20.10, 10.0.20.11

05 / Métier

Recette applicative

Le contrôle technique ne remplace pas le test métier. Pour chaque application, écrivez avant la vague trois à cinq tests concrets (se connecter, ouvrir un dossier, imprimer, générer un export, lancer un traitement) et faites-les exécuter par un utilisateur référent. La validation est notée avec l’heure, avant l’heure limite de retour arrière.

06 / Clôture

Clôturer la vague

  • Compare-MigrationBaseline.ps1 sans échec, ou écarts documentés et acceptés.
  • Validation métier écrite.
  • Première sauvegarde complète réussie sur la cible, et restauration testée sur au moins une VM de la vague.
  • Supervision et alertes recréées sur la cible.
  • Source éteinte, renommée, réseau déconnecté ; date de suppression planifiée.