387 lines
15 KiB
PowerShell
387 lines
15 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Determines whether the Windows system disk is an LDM (dynamic) disk.
|
|
|
|
.DESCRIPTION
|
|
Run this on the SOURCE VM in vCenter, before migrating with vJailbreak.
|
|
|
|
Windows VMs whose system volume sits on a dynamic disk (Logical Disk Manager)
|
|
cannot be converted by virt-v2v and follow vJailbreak's dedicated SATA-first
|
|
migration path, which has extra prerequisites. Dynamic DATA disks are
|
|
irrelevant - only the disk carrying the system volume changes the outcome.
|
|
|
|
The script is strictly READ-ONLY. It issues no diskpart command other than
|
|
'list disk', 'select volume', 'detail volume' and 'san', none of which modify
|
|
the disk layout.
|
|
|
|
Detection uses four independent signals, so a wrong answer requires several
|
|
of them to agree incorrectly:
|
|
|
|
1. GPT partition type GUID - 5808C8AA-... (LDM metadata)
|
|
AF9B60A0-... (LDM data)
|
|
2. MBR partition type byte - 0x42 (LDM)
|
|
3. WMI Win32_DiskPartition.Type containing "Logical Disk Manager"
|
|
4. diskpart's Dyn column in 'list disk' / 'detail volume'
|
|
|
|
The system volume is mapped to its physical disk(s) by three independent
|
|
routes (Storage module, WMI associators, diskpart). On a dynamic disk the
|
|
Storage-module route fails by design - the partition layer does not model LDM
|
|
volumes - and that failure is itself recorded as corroborating evidence.
|
|
|
|
.PARAMETER LogPath
|
|
Where the transcript is written. Defaults to %TEMP%\vjb-ldm-check.log.
|
|
|
|
.PARAMETER Quiet
|
|
Suppress the per-signal transcript on the console. The verdict banner is
|
|
always printed.
|
|
|
|
.OUTPUTS
|
|
A verdict banner, plus a single machine-readable line:
|
|
VJB_LDM_SYSTEM_DISK=YES|NO|UNKNOWN
|
|
|
|
Exit codes:
|
|
0 NO system disk is basic - normal migration path
|
|
1 YES system disk is LDM - SATA-first migration path
|
|
2 UNKNOWN inconclusive, inspect the log
|
|
|
|
.NOTES
|
|
Requires an elevated PowerShell session.
|
|
Tested against Windows Server 2012 and later. Written to PowerShell 2.0
|
|
syntax so it also runs on Windows Server 2008 R2, where the Storage module
|
|
is absent and signals 1 and 2 are unavailable.
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $LogPath = (Join-Path $env:TEMP 'vjb-ldm-check.log'),
|
|
[switch] $Quiet
|
|
)
|
|
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
# GPT partition type GUIDs that identify an LDM disk.
|
|
$LDM_GPT_TYPES = @(
|
|
'5808c8aa-7e8f-42e0-85d2-e1e90434cfb3', # LDM metadata partition
|
|
'af9b60a0-1431-4f62-bc68-3311714a69ad' # LDM data partition
|
|
)
|
|
# MBR partition type byte 0x42 = LDM.
|
|
$LDM_MBR_TYPE = 66
|
|
|
|
$script:LogLines = New-Object System.Collections.ArrayList
|
|
|
|
# ---------------------------------------------------------------- logging ----
|
|
|
|
function Write-Log {
|
|
param([string] $Level, [string] $Message)
|
|
|
|
$line = '{0} [{1,-5}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
|
[void] $script:LogLines.Add($line)
|
|
|
|
if (-not $Quiet) {
|
|
$color = 'Gray'
|
|
if ($Level -eq 'WARN') { $color = 'Yellow' }
|
|
if ($Level -eq 'ERROR') { $color = 'Red' }
|
|
if ($Level -eq 'HIT') { $color = 'Magenta' }
|
|
Write-Host $line -ForegroundColor $color
|
|
}
|
|
}
|
|
|
|
# ------------------------------------------------------------- primitives ----
|
|
|
|
function Test-Elevated {
|
|
try {
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
|
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Invoke-DiskPart {
|
|
param([string[]] $Commands)
|
|
|
|
$scriptFile = [System.IO.Path]::GetTempFileName()
|
|
try {
|
|
Set-Content -Path $scriptFile -Value $Commands -Encoding ASCII
|
|
$output = & diskpart.exe /s $scriptFile 2>&1
|
|
return @($output | ForEach-Object { [string] $_ })
|
|
} catch {
|
|
Write-Log 'WARN' ("diskpart invocation failed: " + $_.Exception.Message)
|
|
return @()
|
|
} finally {
|
|
Remove-Item $scriptFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Get-Column {
|
|
# Slice a fixed-width diskpart column, bleeding one character either side
|
|
# because diskpart does not always pad exactly to its own rule line.
|
|
param([string] $Line, $Column, [int] $Bleed = 1)
|
|
|
|
$start = $Column.Start - $Bleed
|
|
if ($start -lt 0) { $start = 0 }
|
|
if ($Line.Length -le $start) { return '' }
|
|
|
|
$length = $Column.Length + ($Column.Start - $start) + $Bleed
|
|
if (($start + $length) -gt $Line.Length) { $length = $Line.Length - $start }
|
|
return $Line.Substring($start, $length)
|
|
}
|
|
|
|
function Read-DiskPartDiskTable {
|
|
# Both 'list disk' and 'detail volume' emit the same six-column table:
|
|
#
|
|
# Disk ### Status Size Free Dyn Gpt
|
|
# -------- ------------- ------- ------- --- ---
|
|
# Disk 0 Online 100 GB 0 B *
|
|
#
|
|
# Column offsets are taken from the rule line rather than from the header,
|
|
# so parsing does not depend on the console language.
|
|
param([string[]] $Lines)
|
|
|
|
$rows = @()
|
|
$ruleIndex = -1
|
|
$columns = @()
|
|
|
|
for ($i = 0; $i -lt $Lines.Count; $i++) {
|
|
if ($Lines[$i] -match '^\s*-{3,}(\s+-{2,}){3,}\s*$') {
|
|
$ruleIndex = $i
|
|
foreach ($match in ([regex] '-{2,}').Matches($Lines[$i])) {
|
|
$columns += New-Object PSObject -Property @{
|
|
Start = $match.Index
|
|
Length = $match.Length
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($ruleIndex -lt 0 -or $columns.Count -lt 5) {
|
|
Write-Log 'WARN' 'Could not locate the diskpart column rule line; skipping this signal.'
|
|
return $rows
|
|
}
|
|
|
|
for ($i = $ruleIndex + 1; $i -lt $Lines.Count; $i++) {
|
|
if ($Lines[$i].Trim().Length -eq 0) { break }
|
|
|
|
$number = [regex]::Match((Get-Column $Lines[$i] $columns[0] 0), '\d+')
|
|
if (-not $number.Success) { continue }
|
|
|
|
$gpt = $false
|
|
if ($columns.Count -ge 6) {
|
|
$gpt = (Get-Column $Lines[$i] $columns[5]).Contains('*')
|
|
}
|
|
|
|
$rows += New-Object PSObject -Property @{
|
|
Number = [int] $number.Value
|
|
Dynamic = (Get-Column $Lines[$i] $columns[4]).Contains('*')
|
|
Gpt = $gpt
|
|
}
|
|
}
|
|
|
|
return $rows
|
|
}
|
|
|
|
# ------------------------------------------------------------ inspection -----
|
|
|
|
function Get-DynamicDiskEvidence {
|
|
# Returns the disk numbers that at least one signal reports as LDM.
|
|
param([bool] $StorageModule)
|
|
|
|
$dynamic = @{}
|
|
|
|
# Signal 4 - diskpart Dyn column.
|
|
foreach ($row in (Read-DiskPartDiskTable (Invoke-DiskPart @('list disk')))) {
|
|
$state = 'basic'
|
|
if ($row.Dynamic) { $state = 'DYNAMIC'; $dynamic[$row.Number] = $true }
|
|
Write-Log 'INFO' ('diskpart : disk {0} -> {1}' -f $row.Number, $state)
|
|
}
|
|
|
|
# Signal 3 - WMI partition type string.
|
|
foreach ($partition in @(Get-WmiObject -Class Win32_DiskPartition -ErrorAction SilentlyContinue)) {
|
|
if ($partition.Type -match 'Logical Disk Manager|LDM') {
|
|
$dynamic[[int] $partition.DiskIndex] = $true
|
|
Write-Log 'HIT' ('Win32_DiskPartition: disk {0} partition type "{1}"' -f $partition.DiskIndex, $partition.Type)
|
|
} else {
|
|
Write-Log 'INFO' ('Win32_DiskPartition: disk {0} partition type "{1}"' -f $partition.DiskIndex, $partition.Type)
|
|
}
|
|
}
|
|
|
|
# Signals 1 and 2 - GPT type GUID and MBR type byte. Storage module only.
|
|
if ($StorageModule) {
|
|
foreach ($partition in @(Get-Partition -ErrorAction SilentlyContinue)) {
|
|
$gptType = ''
|
|
if ($partition.GptType) { $gptType = ($partition.GptType -replace '[{}]', '').ToLower() }
|
|
|
|
if ($LDM_GPT_TYPES -contains $gptType) {
|
|
$dynamic[[int] $partition.DiskNumber] = $true
|
|
Write-Log 'HIT' ('GPT type GUID : disk {0} partition {1} -> {2}' -f $partition.DiskNumber, $partition.PartitionNumber, $gptType)
|
|
} elseif ([int] $partition.MbrType -eq $LDM_MBR_TYPE) {
|
|
$dynamic[[int] $partition.DiskNumber] = $true
|
|
Write-Log 'HIT' ('MBR type byte : disk {0} partition {1} -> 0x42 (LDM)' -f $partition.DiskNumber, $partition.PartitionNumber)
|
|
}
|
|
}
|
|
} else {
|
|
Write-Log 'WARN' 'Storage module unavailable; partition-type signals skipped (expected on Server 2008 R2).'
|
|
}
|
|
|
|
return @($dynamic.Keys | ForEach-Object { [int] $_ })
|
|
}
|
|
|
|
function Get-SystemVolumeDiskNumber {
|
|
# Three independent routes. Returns the union of whatever resolves.
|
|
param([string] $DriveLetter, [bool] $StorageModule)
|
|
|
|
$found = @{}
|
|
|
|
# Route 1 - Storage module. Fails by design when the volume is LDM.
|
|
if ($StorageModule) {
|
|
$partition = Get-Partition -DriveLetter $DriveLetter -ErrorAction SilentlyContinue
|
|
if ($partition) {
|
|
$hit = @()
|
|
foreach ($p in @($partition)) {
|
|
$found[[int] $p.DiskNumber] = $true
|
|
$hit += [string] $p.DiskNumber
|
|
}
|
|
Write-Log 'INFO' ('Get-Partition : {0}: resolves to disk {1}' -f $DriveLetter, ($hit -join ', '))
|
|
} else {
|
|
Write-Log 'WARN' ('Get-Partition : {0}: has no partition object. The partition layer does not model LDM volumes, so this is expected on a dynamic disk.' -f $DriveLetter)
|
|
}
|
|
}
|
|
|
|
# Route 2 - WMI associators. Also unavailable for LDM volumes.
|
|
$query = "ASSOCIATORS OF {{Win32_LogicalDisk.DeviceID='{0}:'}} WHERE AssocClass=Win32_LogicalDiskToPartition" -f $DriveLetter
|
|
$associated = @(Get-WmiObject -Query $query -ErrorAction SilentlyContinue)
|
|
if ($associated.Count -gt 0) {
|
|
$hit = @()
|
|
foreach ($p in $associated) {
|
|
$found[[int] $p.DiskIndex] = $true
|
|
$hit += [string] $p.DiskIndex
|
|
}
|
|
Write-Log 'INFO' ('WMI associator: {0}: resolves to disk {1}' -f $DriveLetter, ($hit -join ', '))
|
|
} else {
|
|
Write-Log 'WARN' ('WMI associator: {0}: has no Win32_LogicalDiskToPartition association, which is typical for an LDM volume.' -f $DriveLetter)
|
|
}
|
|
|
|
# Route 3 - diskpart. Works for both basic and dynamic volumes.
|
|
$detail = Invoke-DiskPart @(('select volume ' + $DriveLetter), 'detail volume')
|
|
foreach ($row in (Read-DiskPartDiskTable $detail)) {
|
|
$found[$row.Number] = $true
|
|
$state = 'basic'
|
|
if ($row.Dynamic) { $state = 'DYNAMIC' }
|
|
Write-Log 'INFO' ('detail volume : {0}: resides on disk {1} ({2})' -f $DriveLetter, $row.Number, $state)
|
|
}
|
|
|
|
return @($found.Keys | ForEach-Object { [int] $_ })
|
|
}
|
|
|
|
# ------------------------------------------------------------------ main -----
|
|
|
|
Write-Log 'INFO' '=== vJailbreak LDM system-disk probe (read-only) ==='
|
|
Write-Log 'INFO' ('Host : {0}' -f $env:COMPUTERNAME)
|
|
Write-Log 'INFO' ('OS : {0}' -f (Get-WmiObject -Class Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption)
|
|
Write-Log 'INFO' ('PowerShell : {0}' -f $PSVersionTable.PSVersion.ToString())
|
|
Write-Log 'INFO' ('System drive : {0}' -f $env:SystemDrive)
|
|
|
|
if (-not (Test-Elevated)) {
|
|
Write-Log 'ERROR' 'Not running elevated. diskpart and the Storage module need Administrator rights.'
|
|
Write-Log 'ERROR' 'Re-run this script from an elevated PowerShell session.'
|
|
$verdict = 'UNKNOWN'
|
|
} else {
|
|
$driveLetter = $env:SystemDrive.Substring(0, 1)
|
|
$storageModule = $null -ne (Get-Command Get-Disk -ErrorAction SilentlyContinue)
|
|
Write-Log 'INFO' ('Storage module: {0}' -f $(if ($storageModule) { 'available' } else { 'NOT available' }))
|
|
Write-Log 'INFO' '--- mapping the system volume to its physical disk(s) ---'
|
|
|
|
$systemDisks = Get-SystemVolumeDiskNumber -DriveLetter $driveLetter -StorageModule $storageModule
|
|
|
|
Write-Log 'INFO' '--- collecting LDM evidence for every disk ---'
|
|
$dynamicDisks = Get-DynamicDiskEvidence -StorageModule $storageModule
|
|
|
|
Write-Log 'INFO' '--- summary ---'
|
|
Write-Log 'INFO' ('System volume resides on disk(s) : {0}' -f $(if ($systemDisks.Count) { ($systemDisks | Sort-Object) -join ', ' } else { '<unresolved>' }))
|
|
Write-Log 'INFO' ('Disks reported as LDM/dynamic : {0}' -f $(if ($dynamicDisks.Count) { ($dynamicDisks | Sort-Object) -join ', ' } else { 'none' }))
|
|
|
|
if ($systemDisks.Count -gt 0) {
|
|
$hit = @($systemDisks | Where-Object { $dynamicDisks -contains $_ })
|
|
if ($hit.Count -gt 0) {
|
|
$verdict = 'YES'
|
|
Write-Log 'HIT' ('System volume resides on dynamic disk(s): {0}' -f (($hit | Sort-Object) -join ', '))
|
|
} else {
|
|
$verdict = 'NO'
|
|
$dataOnly = @($dynamicDisks | Where-Object { $systemDisks -notcontains $_ })
|
|
if ($dataOnly.Count -gt 0) {
|
|
Write-Log 'INFO' ('Disk(s) {0} are dynamic but carry no system volume. Data disks do not affect the migration path.' -f (($dataOnly | Sort-Object) -join ', '))
|
|
}
|
|
}
|
|
} elseif ($dynamicDisks.Count -gt 0) {
|
|
# No route could map the system volume to a disk, and dynamic disks
|
|
# exist. Both mapping failures are themselves LDM symptoms.
|
|
$verdict = 'YES'
|
|
Write-Log 'HIT' 'The system volume could not be mapped to any partition object while dynamic disks are present. Both facts point to an LDM system volume.'
|
|
} else {
|
|
$verdict = 'UNKNOWN'
|
|
Write-Log 'ERROR' 'The system volume could not be mapped to a disk and no LDM evidence was found. Inspect the log and check the disk layout manually.'
|
|
}
|
|
|
|
# Informational only - not part of the verdict. The SAN policy is a
|
|
# prerequisite for the LDM migration path.
|
|
$sanOutput = Invoke-DiskPart @('san')
|
|
foreach ($line in $sanOutput) {
|
|
if ($line -match ':') {
|
|
$trimmed = $line.Trim()
|
|
if ($trimmed -match 'Online|Offline') {
|
|
Write-Log 'INFO' ('SAN policy : {0}' -f $trimmed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Persist the transcript before anything else can fail.
|
|
try {
|
|
Set-Content -Path $LogPath -Value $script:LogLines -Encoding ASCII
|
|
Write-Host ''
|
|
Write-Host ('Full transcript: {0}' -f $LogPath) -ForegroundColor DarkGray
|
|
} catch {
|
|
Write-Host ''
|
|
Write-Host ('Could not write the transcript to {0}: {1}' -f $LogPath, $_.Exception.Message) -ForegroundColor Yellow
|
|
}
|
|
|
|
# ---------------------------------------------------------------- verdict ----
|
|
# Printed last, on its own, so it is never buried in the transcript above.
|
|
|
|
$banner = 'Gray'
|
|
if ($verdict -eq 'YES') { $banner = 'Red' }
|
|
if ($verdict -eq 'NO') { $banner = 'Green' }
|
|
if ($verdict -eq 'UNKNOWN') { $banner = 'Yellow' }
|
|
|
|
# Built by measurement rather than by hand-counted spaces, so the box always
|
|
# closes regardless of the verdict length.
|
|
$width = 64
|
|
$rule = '#' * $width
|
|
$blank = '#' + (' ' * ($width - 2)) + '#'
|
|
$label = ' SYSTEM DISK IS LDM (DYNAMIC): ' + $verdict
|
|
$pad = $width - 2 - $label.Length
|
|
if ($pad -lt 0) { $pad = 0 }
|
|
|
|
Write-Host ''
|
|
Write-Host $rule
|
|
Write-Host $blank
|
|
Write-Host ('#' + $label + (' ' * $pad) + '#') -ForegroundColor $banner
|
|
Write-Host $blank
|
|
Write-Host $rule
|
|
Write-Host ''
|
|
|
|
if ($verdict -eq 'YES') {
|
|
Write-Host 'This VM takes the SATA-first migration path. Install the VirtIO guest' -ForegroundColor Yellow
|
|
Write-Host 'tools and set "diskpart > san policy=onlineall" on THIS VM before' -ForegroundColor Yellow
|
|
Write-Host 'migrating, and expect a manual cutover at LDM Boot Verification.' -ForegroundColor Yellow
|
|
Write-Host ''
|
|
}
|
|
|
|
Write-Host ('VJB_LDM_SYSTEM_DISK={0}' -f $verdict)
|
|
|
|
if ($verdict -eq 'YES') { exit 1 }
|
|
if ($verdict -eq 'NO') { exit 0 }
|
|
exit 2
|