Files
vjailbreak/docs/public/scripts/Test-VjbLdmSystemDisk.ps1
T
2026-09-12 22:24:48 +05:30

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