Guide technique ANYSYS · vérifié le 26 octobre 2026
Recette post-migration Windows : relevé avant/après en PowerShell

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.
| Domaine | Ce qui est comparé | Pourquoi |
|---|---|---|
| Réseau | Adresses IPv4, passerelle, DNS | Le changement de carte virtuelle fait perdre ou dupliquer la configuration |
| Cartes fantômes | Cartes non présentes | Elles retiennent l’ancienne adresse statique |
| Stockage | Disques hors ligne ou en lecture seule, lettres de lecteur | Stratégie SAN et changement de contrôleur |
| Services | Services automatiques qui tournaient avant | Dépendances matérielles ou réseau cassées |
| Ports | Ports en écoute (hors boucle locale) | Applications démarrées mais sans écoute |
| Identité | Canal sécurisé du domaine, activation Windows | Changement de matériel virtuel et horloge |
| Journaux | Nouvelles sources d’erreurs système et application | Signal faible sur les problèmes non couverts |
02 / Capture
Capturer l’état de référence
<#
.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"# 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.jsonLe 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
<#
.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.\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 :
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
| Échec | Cause habituelle | Correction |
|---|---|---|
| IPv4 addresses (adresse 169.254.x.x) | Configuration statique restée sur l’ancienne carte | Supprimer la carte fantôme, puis reconfigurer : New-NetIPAddress et Set-DnsClientServerAddress |
| No ghost network adapter | Ancienne carte VMXNET3 non présente | pnputil /remove-device <InstanceId> (Windows Server 2022 et versions ultérieures), sinon Gestionnaire de périphériques |
| All disks online | Stratégie SAN OfflineShared | Set-Disk -Number 1 -IsOffline $false, puis Set-StorageSetting -NewDiskPolicy OnlineAll |
| All drive letters present | Disque hors ligne ou lettre réattribuée | Remettre le disque en ligne, puis Set-Partition -DriveLetter E -NewDriveLetter D si nécessaire |
| Domain secure channel | Horloge ou réseau incorrects au premier démarrage | Corriger le réseau d’abord, puis Test-ComputerSecureChannel -Repair -Credential (Get-Credential) |
| Windows activation | Changement de matériel virtuel | Réactiver selon le modèle de licence (KMS, AVMA sur Hyper-V Datacenter, clé MAK) |
# 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.1105 / 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.