1701 lines
76 KiB
PowerShell
1701 lines
76 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Safely (re)creates the Recovery Partition (WinRE) at the end of the
|
|
system disk and, on request, expands C: into the remaining space.
|
|
|
|
.DESCRIPTION
|
|
Multi-phase, highly defensive script:
|
|
PHASE 1 - DISCOVERY (read-only)
|
|
PHASE 2 - VALIDATION (read-only)
|
|
PHASE 3 - USER CONFIRMATION
|
|
PHASE 4 - DESTRUCTIVE OPERATION (remove old Recovery, if present)
|
|
PHASE 5 - REBUILD (create new Recovery FIRST at the TRUE end
|
|
of the disk using an explicit -Offset,
|
|
then optionally expand C: into whatever
|
|
remains, THEN enable WinRE)
|
|
PHASE 6 - VALIDATION (final audit)
|
|
PHASE 7 - FINAL REPORT
|
|
|
|
v2.1.0 CHANGES (bugfix release):
|
|
- New-RecoveryPartition now computes an EXPLICIT end-of-disk -Offset
|
|
(disk.Size - buffer - RecoverySizeMB) instead of letting
|
|
New-Partition auto-place it. Auto-placement (no -Offset) puts a
|
|
new partition at the START of the first available free extent -
|
|
NOT the end. Since the free extent runs from the end of C: to the
|
|
end of the disk, this glued Recovery directly onto C: and pushed
|
|
ALL remaining free space past Recovery, to the true end of the
|
|
disk - the opposite of the intended design, and it also meant the
|
|
gap-to-expand-C-into was always computed as ~0 MB, so the script
|
|
never asked to expand C:.
|
|
- The interactive "expand C: into remaining space?" question is now
|
|
asked BEFORE reagentc /enable, not after. Recovery is created at
|
|
the true end of the disk (so there can be a gap between C: and
|
|
Recovery at that point); if adjacency between C: and Recovery
|
|
really is required for reagentc to accept the partition (per this
|
|
script's own prior testing notes), closing that gap has to happen
|
|
before WinRE is enabled, not after.
|
|
- The old "blocking partition directly after C:" hard-abort guard
|
|
(which assumed Recovery must always sit immediately after C:) was
|
|
relaxed to an informational check, since Recovery is no longer
|
|
required to be placed immediately after C: at creation time.
|
|
|
|
An existing Recovery Partition is OPTIONAL on input. If none is found,
|
|
the script treats this as a fresh-build scenario (e.g. after a manual
|
|
diskpart shrink) instead of failing.
|
|
|
|
The script NEVER assumes Disk 0, Partition 4, or any fixed size. Every
|
|
disk/partition reference is re-detected dynamically and re-validated
|
|
immediately before any destructive action.
|
|
|
|
.PARAMETER AuditOnly
|
|
Performs full discovery + validation, prints the plan, but makes NO
|
|
changes to the system.
|
|
|
|
.PARAMETER RecoverySizeMB
|
|
Target size (MB) of the new Recovery Partition. Default 2000. NOTE:
|
|
1024 MB is NOT reliably enough on modern Windows 11 - reagentc /enable
|
|
silently falls back to storing WinRE inside C: itself (instead of using
|
|
the Recovery partition) if the partition is too small for the current
|
|
Winre.wim plus its own required working-space buffer, even though the
|
|
partition otherwise looks perfectly valid. The script also checks the
|
|
actual current Winre.wim size at runtime and warns if this value is
|
|
too small for it.
|
|
|
|
.PARAMETER DiskEndBufferMB
|
|
Small safety buffer (MB) left truly unallocated at the very end of the
|
|
disk, to avoid colliding with the GPT backup header / alignment
|
|
rounding. Default 1.
|
|
|
|
.PARAMETER LogPath
|
|
Directory for the log file. Default C:\Temp.
|
|
|
|
.PARAMETER SkipBanner
|
|
Suppresses the ASCII banner.
|
|
|
|
.EXAMPLE
|
|
.\WinRE-Resize2.ps1 -AuditOnly
|
|
|
|
.EXAMPLE
|
|
.\WinRE-Resize2.ps1 -RecoverySizeMB 1024
|
|
|
|
.NOTES
|
|
Author : F.Š. | Windows Recovery Partition Tool
|
|
Version: 2.1.0
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch]$AuditOnly,
|
|
[int]$RecoverySizeMB = 2000,
|
|
[int]$DiskEndBufferMB = 1,
|
|
[string]$LogPath = "C:\Temp",
|
|
[switch]$SkipBanner
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# ============================================================================
|
|
# GLOBAL STATE
|
|
# ============================================================================
|
|
$Script:ToolVersion = "2.1.0"
|
|
$Script:LogFile = $null
|
|
$Script:TranscriptFile = $null
|
|
$Script:CurrentStep = 0
|
|
$Script:TotalSteps = 14
|
|
$Script:RecoveryGptType = "de94bba4-06d1-4d40-a16a-bfd50179d6ac"
|
|
$Script:RecoveryMbrType = 27
|
|
$Script:RecoveryGptAttrs = "0x8000000000000001"
|
|
$Script:BitLockerSuspended = $false # true while WE hold BitLocker protection suspended
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Write-Log
|
|
# ============================================================================
|
|
function Write-Log {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Message,
|
|
[ValidateSet('INFO','WARNING','ERROR','SUCCESS','DEBUG')][string]$Level = 'INFO'
|
|
)
|
|
|
|
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
$hostName = $env:COMPUTERNAME
|
|
$line = "[$timestamp] [$Level] [$hostName] $Message"
|
|
|
|
if ($Script:LogFile) {
|
|
try { Add-Content -Path $Script:LogFile -Value $line -ErrorAction Stop }
|
|
catch { Write-Host "LOG WRITE FAILED: $_" -ForegroundColor Red }
|
|
}
|
|
|
|
switch ($Level) {
|
|
'WARNING' { Write-Host $line -ForegroundColor Yellow }
|
|
'ERROR' { Write-Host $line -ForegroundColor Red }
|
|
'SUCCESS' { Write-Host $line -ForegroundColor Green }
|
|
'DEBUG' { Write-Host $line -ForegroundColor DarkGray }
|
|
default { Write-Host $line }
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Write-Banner
|
|
# ============================================================================
|
|
function Write-Banner {
|
|
if ($SkipBanner) { return }
|
|
|
|
$banner = @"
|
|
███████╗███████╗
|
|
██╔════╝██╔════╝
|
|
█████╗ ███████╗
|
|
██╔══╝ ╚════██║
|
|
██║ ███████║
|
|
╚═╝ ╚══════╝
|
|
|
|
F.Š. | Windows Recovery Partition Tool
|
|
"@
|
|
Write-Host $banner -ForegroundColor Cyan
|
|
Write-Host "Version : $Script:ToolVersion"
|
|
Write-Host "Computer : $env:COMPUTERNAME"
|
|
Write-Host "User : $env:USERDOMAIN\$env:USERNAME"
|
|
Write-Host "Started : $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')"
|
|
Write-Host "PS Ver : $($PSVersionTable.PSVersion.ToString())"
|
|
Write-Host "OS : $((Get-CimInstance Win32_OperatingSystem).Caption)"
|
|
if ($AuditOnly) {
|
|
Write-Host "MODE : AUDIT ONLY - no changes will be made" -ForegroundColor Yellow
|
|
}
|
|
Write-Host ("=" * 60)
|
|
Write-Host ""
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Write-Step
|
|
# ============================================================================
|
|
function Write-Step {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Description,
|
|
[ValidateSet('START','OK','FAIL','SKIP')][string]$Status = 'START'
|
|
)
|
|
|
|
if ($Status -eq 'START') {
|
|
$Script:CurrentStep++
|
|
$prefix = "[{0}/{1}]" -f $Script:CurrentStep, $Script:TotalSteps
|
|
Write-Host ("{0} {1}" -f $prefix, $Description.PadRight(45, '.')) -NoNewline
|
|
Write-Log -Message "STEP START: $Description" -Level 'INFO'
|
|
}
|
|
else {
|
|
$color = switch ($Status) { 'OK' {'Green'} 'FAIL' {'Red'} 'SKIP' {'Yellow'} }
|
|
Write-Host " [$Status]" -ForegroundColor $color
|
|
Write-Log -Message "STEP ${Status}: $Description" -Level $(if ($Status -eq 'FAIL') {'ERROR'} else {'INFO'})
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Stop-Safely
|
|
# ============================================================================
|
|
function Stop-Safely {
|
|
param(
|
|
[string]$Reason = "User requested termination",
|
|
[int]$ExitCode = 1
|
|
)
|
|
|
|
if ($Script:BitLockerSuspended) {
|
|
Write-Log -Message "Aborting with BitLocker still suspended - resuming protection before exit." -Level 'WARNING'
|
|
Resume-BitLockerProtection -MountPoint 'C:'
|
|
$Script:BitLockerSuspended = $false
|
|
}
|
|
|
|
Write-Log -Message "SCRIPT TERMINATED: $Reason" -Level 'ERROR'
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "SCRIPT STOPPED" -ForegroundColor Red
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "Reason : $Reason" -ForegroundColor Red
|
|
if ($Script:LogFile) { Write-Host "Log : $Script:LogFile" -ForegroundColor Red }
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
|
|
try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
|
|
exit $ExitCode
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Test-IsAdministrator
|
|
# ============================================================================
|
|
function Test-IsAdministrator {
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
|
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Confirm-YesNo (generic Y/N/Q prompt helper)
|
|
# ============================================================================
|
|
function Confirm-YesNo {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Prompt
|
|
)
|
|
|
|
while ($true) {
|
|
Write-Host ""
|
|
Write-Host $Prompt
|
|
Write-Host "[Y] Yes [N] No [Q] Quit" -ForegroundColor Cyan
|
|
$answer = Read-Host "Your choice"
|
|
switch ($answer.Trim().ToUpper()) {
|
|
'Y' { return $true }
|
|
'N' { return $false }
|
|
'Q' { Stop-Safely -Reason "User aborted at confirmation prompt" }
|
|
default { Write-Host "Invalid input, please type Y, N or Q." -ForegroundColor Yellow }
|
|
}
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Invoke-NativeCommand
|
|
# Runs an external .exe and captures stdout+stderr WITHOUT letting
|
|
# $ErrorActionPreference = 'Stop' turn stderr lines into terminating
|
|
# errors.
|
|
# ============================================================================
|
|
function Invoke-NativeCommand {
|
|
param(
|
|
[Parameter(Mandatory)][string]$FilePath,
|
|
[string[]]$ArgumentList = @()
|
|
)
|
|
|
|
$previousEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
$global:LASTEXITCODE = 0
|
|
try {
|
|
if ($ArgumentList.Count -gt 0) {
|
|
$rawOutput = & $FilePath @ArgumentList 2>&1
|
|
}
|
|
else {
|
|
$rawOutput = & $FilePath 2>&1
|
|
}
|
|
}
|
|
finally {
|
|
$ErrorActionPreference = $previousEAP
|
|
}
|
|
|
|
$lines = @($rawOutput | ForEach-Object { $_.ToString() })
|
|
|
|
return [PSCustomObject]@{
|
|
Output = $lines
|
|
ExitCode = $LASTEXITCODE
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-SystemDisk
|
|
# ============================================================================
|
|
function Get-SystemDisk {
|
|
Write-Log -Message "Detecting system disk hosting C:\" -Level 'INFO'
|
|
|
|
$cPartition = Get-Partition -DriveLetter C -ErrorAction Stop
|
|
$disk = Get-Disk -Number $cPartition.DiskNumber -ErrorAction Stop
|
|
|
|
Write-Log -Message "System disk detected: Disk $($disk.Number) ($($disk.FriendlyName))" -Level 'SUCCESS'
|
|
|
|
Write-Host ""
|
|
Write-Host "SYSTEM DISK" -ForegroundColor Cyan
|
|
Write-Host "Disk Number : $($disk.Number)"
|
|
Write-Host "Model : $($disk.FriendlyName)"
|
|
Write-Host "Serial Number : $($disk.SerialNumber)"
|
|
Write-Host "Bus Type : $($disk.BusType)"
|
|
Write-Host "Partition Style : $($disk.PartitionStyle)"
|
|
Write-Host "Size : $([math]::Round($disk.Size/1GB,2)) GB"
|
|
Write-Host "Operational Status : $($disk.OperationalStatus)"
|
|
Write-Host "Health Status : $($disk.HealthStatus)"
|
|
|
|
return [PSCustomObject]@{
|
|
Disk = $disk
|
|
CPartition = $cPartition
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-DiskLayout
|
|
# ============================================================================
|
|
function Get-DiskLayout {
|
|
param([Parameter(Mandatory)][int]$DiskNumber)
|
|
|
|
$partitions = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset
|
|
|
|
Write-Host ""
|
|
Write-Host "CURRENT PARTITION LAYOUT (Disk $DiskNumber)" -ForegroundColor Cyan
|
|
Write-Host ("{0,-6} {1,-12} {2,-10} {3,-16} {4,-8} {5}" -f "Part#","Type","SizeMB","Offset","Drive","GptType")
|
|
foreach ($p in $partitions) {
|
|
$gptType = $null
|
|
try { $gptType = $p.GptType } catch { $gptType = "n/a" }
|
|
Write-Host ("{0,-6} {1,-12} {2,-10} {3,-16} {4,-8} {5}" -f `
|
|
$p.PartitionNumber, $p.Type, [math]::Round($p.Size/1MB,0), $p.Offset, `
|
|
($(if ($p.DriveLetter) { $p.DriveLetter } else { '-' })), $gptType)
|
|
}
|
|
|
|
return $partitions
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-TrailingFreeSpaceMB
|
|
# ============================================================================
|
|
function Get-TrailingFreeSpaceMB {
|
|
param([Parameter(Mandatory)][int]$DiskNumber)
|
|
|
|
$disk = Get-Disk -Number $DiskNumber
|
|
$freeBytes = $disk.Size - $disk.AllocatedSize
|
|
if ($freeBytes -lt 0) { $freeBytes = 0 }
|
|
return [math]::Floor($freeBytes / 1MB)
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-WinREStatus
|
|
# ============================================================================
|
|
function Get-WinREStatus {
|
|
Write-Log -Message "Querying reagentc /info" -Level 'INFO'
|
|
|
|
$result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/info')
|
|
$raw = $result.Output
|
|
Write-Log -Message "reagentc /info output (exit $($result.ExitCode)): `n$($raw -join "`n")" -Level 'DEBUG'
|
|
|
|
$statusLine = $raw | Where-Object { $_ -match 'Windows RE status' }
|
|
$locationLine = $raw | Where-Object { $_ -match 'Windows RE location' }
|
|
|
|
$status = if ($statusLine -match ':\s*(.+)$') { $Matches[1].Trim() } else { 'Unknown' }
|
|
$location = if ($locationLine -match 'harddisk(\d+)\\partition(\d+)') {
|
|
[PSCustomObject]@{ DiskNumber = [int]$Matches[1]; PartitionNumber = [int]$Matches[2] }
|
|
} else { $null }
|
|
|
|
return [PSCustomObject]@{
|
|
Status = $status
|
|
Location = $location
|
|
RawOutput = $raw
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-RecoveryPartition
|
|
# ============================================================================
|
|
function Get-RecoveryPartition {
|
|
param(
|
|
[Parameter(Mandatory)]$Disk,
|
|
[Parameter(Mandatory)]$Partitions,
|
|
[Parameter(Mandatory)]$CPartition,
|
|
[Parameter(Mandatory)]$WinREStatus
|
|
)
|
|
|
|
Write-Log -Message "Detecting Recovery Partition candidates" -Level 'INFO'
|
|
|
|
$candidates = @()
|
|
|
|
foreach ($p in $Partitions) {
|
|
if ($p.PartitionNumber -eq $CPartition.PartitionNumber) { continue }
|
|
|
|
$isCandidate = $false
|
|
$typeMatches = $false
|
|
$reasons = @()
|
|
|
|
if ($Disk.PartitionStyle -eq 'GPT') {
|
|
try {
|
|
if ($p.GptType -and ($p.GptType.Trim('{}').ToLower() -eq $Script:RecoveryGptType)) {
|
|
$typeMatches = $true
|
|
}
|
|
} catch {}
|
|
}
|
|
elseif ($Disk.PartitionStyle -eq 'MBR') {
|
|
try {
|
|
if ($p.MbrType -eq $Script:RecoveryMbrType) {
|
|
$typeMatches = $true
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if ($typeMatches) {
|
|
$isCandidate = $true
|
|
$reasons += if ($Disk.PartitionStyle -eq 'GPT') { "GptType matches Recovery GUID" } else { "MbrType = 27" }
|
|
if ($p.DriveLetter) {
|
|
$reasons += "WARNING: has drive letter $($p.DriveLetter): - unusual for a genuine Recovery partition, verify manually"
|
|
} else {
|
|
$reasons += "No drive letter"
|
|
}
|
|
}
|
|
|
|
if ($p.Offset -gt $CPartition.Offset) { $reasons += "Located after C:" }
|
|
|
|
if ($WinREStatus.Location -and $WinREStatus.Location.PartitionNumber -eq $p.PartitionNumber) {
|
|
$isCandidate = $true
|
|
$reasons += "Matches reagentc /info WinRE location"
|
|
}
|
|
|
|
if (-not $isCandidate -and -not $p.DriveLetter -and $p.Offset -ge ($CPartition.Offset + $CPartition.Size) -and ($p.Offset - ($CPartition.Offset + $CPartition.Size)) -lt 1MB -and $p.Size -le 4096MB) {
|
|
$isCandidate = $true
|
|
$reasons += "UNRECOGNIZED TYPE but immediately after C: with no gap and no drive letter - likely an orphaned Recovery-ish partition"
|
|
}
|
|
|
|
if ($isCandidate) {
|
|
$candidates += [PSCustomObject]@{
|
|
Partition = $p
|
|
Reasons = $reasons
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($candidates.Count -eq 0) {
|
|
Write-Log -Message "No existing Recovery Partition detected on this disk." -Level 'INFO'
|
|
return $null
|
|
}
|
|
|
|
if ($candidates.Count -gt 1) {
|
|
Write-Log -Message "Multiple Recovery Partition candidates found ($($candidates.Count)) - cannot auto-select" -Level 'ERROR'
|
|
Write-Host ""
|
|
Write-Host "AMBIGUOUS: multiple possible Recovery partitions detected:" -ForegroundColor Red
|
|
foreach ($c in $candidates) {
|
|
Write-Host " - Partition $($c.Partition.PartitionNumber): $($c.Reasons -join '; ')"
|
|
}
|
|
return $null
|
|
}
|
|
|
|
$winner = $candidates[0]
|
|
Write-Log -Message "Recovery Partition identified: Partition $($winner.Partition.PartitionNumber) [$($winner.Reasons -join '; ')]" -Level 'SUCCESS'
|
|
|
|
return $winner.Partition
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Test-DiskLayout
|
|
# ============================================================================
|
|
function Test-DiskLayout {
|
|
param(
|
|
[Parameter(Mandatory)]$Partitions,
|
|
[Parameter(Mandatory)]$CPartition,
|
|
[Parameter(Mandatory)]$RecoveryPartition
|
|
)
|
|
|
|
$sorted = $Partitions | Sort-Object Offset
|
|
$cIndex = [array]::IndexOf($sorted.PartitionNumber, $CPartition.PartitionNumber)
|
|
$rIndex = [array]::IndexOf($sorted.PartitionNumber, $RecoveryPartition.PartitionNumber)
|
|
|
|
if ($rIndex -ne ($cIndex + 1)) {
|
|
Write-Log -Message "Recovery partition is NOT directly behind C: (other partitions in between)" -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "Disk layout validated: Recovery partition is immediately behind C:" -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Confirm-RecoveryPartition
|
|
# ============================================================================
|
|
function Confirm-RecoveryPartition {
|
|
param(
|
|
[Parameter(Mandatory)][int]$DiskNumber,
|
|
[Parameter(Mandatory)]$RecoveryPartition,
|
|
[Parameter(Mandatory)]$CPartition
|
|
)
|
|
|
|
$gptType = $null
|
|
try { $gptType = $RecoveryPartition.GptType } catch { $gptType = "n/a" }
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "RECOVERY PARTITION DETECTION" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "The script believes the following partition is WinRE:"
|
|
Write-Host ""
|
|
Write-Host "Disk Number : $DiskNumber"
|
|
Write-Host "Partition Number : $($RecoveryPartition.PartitionNumber)"
|
|
Write-Host "Size : $([math]::Round($RecoveryPartition.Size/1MB,0)) MB"
|
|
Write-Host "Offset : $($RecoveryPartition.Offset)"
|
|
Write-Host "Type : $($RecoveryPartition.Type)"
|
|
Write-Host "GPT Type : $gptType"
|
|
Write-Host "Drive Letter : $(if ($RecoveryPartition.DriveLetter) { $RecoveryPartition.DriveLetter } else { 'None' })"
|
|
Write-Host "Position : $(if ($RecoveryPartition.Offset -gt $CPartition.Offset) { 'After C:' } else { 'Before C:' })"
|
|
|
|
return Confirm-YesNo -Prompt "Is this the CORRECT Recovery Partition (to be deleted and recreated)?"
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Confirm-Snapshot
|
|
# ============================================================================
|
|
function Confirm-Snapshot {
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host "IMPORTANT - VM SNAPSHOT / CHECKPOINT" -ForegroundColor Yellow
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host @"
|
|
Before continuing, verify that this VM does NOT have an
|
|
active snapshot (VMware) or checkpoint (Hyper-V).
|
|
|
|
This script cannot guarantee that VMware snapshots or
|
|
Hyper-V checkpoints are correctly detected from inside
|
|
Windows.
|
|
|
|
Recommended:
|
|
- Verify VMware snapshot / Hyper-V checkpoint state manually.
|
|
- Verify current backup exists and is valid.
|
|
"@
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
|
|
return Confirm-YesNo -Prompt "Have you verified that the VM has no active snapshot/checkpoint (VMware or Hyper-V)?"
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Confirm-Backup
|
|
# ============================================================================
|
|
function Confirm-Backup {
|
|
return Confirm-YesNo -Prompt "Have you verified that a recent successful backup exists?"
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-BitLockerInfo
|
|
# ============================================================================
|
|
function Get-BitLockerInfo {
|
|
param([string]$MountPoint = 'C:')
|
|
|
|
try {
|
|
return Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Write-Log -Message "Could not query BitLocker status for ${MountPoint}: $_" -Level 'DEBUG'
|
|
return $null
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Suspend-BitLockerProtection
|
|
# ============================================================================
|
|
function Suspend-BitLockerProtection {
|
|
param([string]$MountPoint = 'C:')
|
|
|
|
$info = Get-BitLockerInfo -MountPoint $MountPoint
|
|
if (-not $info -or $info.VolumeStatus -eq 'FullyDecrypted') {
|
|
return $false
|
|
}
|
|
if ($info.ProtectionStatus -ne 'On') {
|
|
Write-Log -Message "BitLocker on $MountPoint is encrypted but protection is already Off - nothing to suspend." -Level 'INFO'
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "Suspending BitLocker protection on $MountPoint for 1 restart (required before reagentc/diskpart touch the boot config)." -Level 'WARNING'
|
|
try {
|
|
Suspend-BitLocker -MountPoint $MountPoint -RebootCount 1 -ErrorAction Stop | Out-Null
|
|
}
|
|
catch {
|
|
Write-Log -Message "Failed to suspend BitLocker on ${MountPoint}: $_" -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "BitLocker protection suspended on $MountPoint." -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Resume-BitLockerProtection
|
|
# ============================================================================
|
|
function Resume-BitLockerProtection {
|
|
param([string]$MountPoint = 'C:')
|
|
|
|
Write-Log -Message "Resuming BitLocker protection on $MountPoint." -Level 'INFO'
|
|
try {
|
|
Resume-BitLocker -MountPoint $MountPoint -ErrorAction Stop | Out-Null
|
|
Write-Log -Message "BitLocker protection resumed on $MountPoint." -Level 'SUCCESS'
|
|
}
|
|
catch {
|
|
Write-Log -Message "FAILED to resume BitLocker protection on ${MountPoint}: $_. Resume it manually: Resume-BitLocker -MountPoint $MountPoint (or 'manage-bde -protectors -enable $MountPoint')." -Level 'ERROR'
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Wait-BitLockerDecryption
|
|
# ============================================================================
|
|
function Wait-BitLockerDecryption {
|
|
param([string]$MountPoint = 'C:')
|
|
|
|
$bitlocker = $null
|
|
while ($true) {
|
|
try {
|
|
$bitlocker = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Write-Log -Message "Could not query BitLocker status for ${MountPoint} while waiting for decryption: $_" -Level 'ERROR'
|
|
break
|
|
}
|
|
|
|
Clear-Host
|
|
Write-Host "BitLocker decryption progress" -ForegroundColor Cyan
|
|
Write-Host "--------------------------------"
|
|
Write-Host "Volume : $($bitlocker.MountPoint)"
|
|
Write-Host "Status : $($bitlocker.VolumeStatus)"
|
|
Write-Host "Progress : $($bitlocker.EncryptionPercentage)%"
|
|
Write-Host "Protection : $($bitlocker.ProtectionStatus)"
|
|
Write-Host ""
|
|
Write-Host "Last check : $(Get-Date)"
|
|
|
|
if ($bitlocker.VolumeStatus -ne 'DecryptionInProgress') {
|
|
Write-Host ""
|
|
Write-Host "Decryption is no longer in progress." -ForegroundColor Green
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Seconds 5
|
|
}
|
|
|
|
return $bitlocker
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Invoke-BitLockerDecryptWorkaround
|
|
# ============================================================================
|
|
function Invoke-BitLockerDecryptWorkaround {
|
|
param([string]$MountPoint = 'C:')
|
|
|
|
$before = Get-BitLockerInfo -MountPoint $MountPoint
|
|
if (-not $before -or $before.VolumeStatus -eq 'FullyDecrypted') {
|
|
return [PSCustomObject]@{ Success = $true; ProtectorTypes = @() }
|
|
}
|
|
|
|
$protectorTypes = @($before.KeyProtector | ForEach-Object { $_.KeyProtectorType })
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "BITLOCKER FULL DECRYPT WORKAROUND" -ForegroundColor Red
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host @"
|
|
reagentc.exe refuses to install WinRE while $MountPoint carries
|
|
ANY BitLocker encryption - confirmed via ReAgent.log: it falls
|
|
back to targeting the OS partition itself and then rejects that
|
|
purely because of BitLocker, even with protection suspended/off.
|
|
|
|
The only known workaround is to FULLY decrypt $MountPoint, enable
|
|
WinRE, then re-encrypt it again. The disk will be UNENCRYPTED for
|
|
the entire decrypt + re-encrypt duration.
|
|
|
|
Protectors currently on ${MountPoint}: $($protectorTypes -join ', ')
|
|
This script can only restore a plain TPM and/or recovery-password
|
|
protector afterwards - NOT any PIN/startup-key/AD-Entra escrow
|
|
setup you may have. You are responsible for reconfiguring that
|
|
and re-escrowing any new recovery key per your organization policy.
|
|
"@
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
|
|
if (-not (Confirm-YesNo -Prompt "Fully decrypt $MountPoint now so WinRE can be enabled, then re-encrypt it afterwards?")) {
|
|
return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes }
|
|
}
|
|
|
|
Write-Log -Message "Disabling BitLocker (full decrypt) on $MountPoint. Protectors before: $($protectorTypes -join ', ')" -Level 'WARNING'
|
|
try {
|
|
Disable-BitLocker -MountPoint $MountPoint -ErrorAction Stop | Out-Null
|
|
}
|
|
catch {
|
|
Write-Log -Message "Disable-BitLocker failed: $_" -Level 'ERROR'
|
|
return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes }
|
|
}
|
|
|
|
$final = Wait-BitLockerDecryption -MountPoint $MountPoint
|
|
|
|
if (-not $final -or $final.VolumeStatus -ne 'FullyDecrypted') {
|
|
$endStatus = if ($final) { $final.VolumeStatus } else { 'Unknown' }
|
|
Write-Log -Message "$MountPoint did not reach FullyDecrypted (ended at $endStatus) - aborting workaround." -Level 'ERROR'
|
|
return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes }
|
|
}
|
|
|
|
Write-Log -Message "$MountPoint is now fully decrypted." -Level 'SUCCESS'
|
|
return [PSCustomObject]@{ Success = $true; ProtectorTypes = $protectorTypes }
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Invoke-BitLockerReencrypt
|
|
# ============================================================================
|
|
function Invoke-BitLockerReencrypt {
|
|
param(
|
|
[string]$MountPoint = 'C:',
|
|
[string[]]$OriginalProtectorTypes = @()
|
|
)
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host "RE-ENCRYPTING $MountPoint" -ForegroundColor Yellow
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
|
|
try {
|
|
if ($OriginalProtectorTypes -contains 'Tpm') {
|
|
Write-Log -Message "Re-enabling BitLocker on $MountPoint with a TPM protector (matches what was there before)." -Level 'INFO'
|
|
Enable-BitLocker -MountPoint $MountPoint -TpmProtector -SkipHardwareTest -ErrorAction Stop | Out-Null
|
|
}
|
|
else {
|
|
Write-Log -Message "Re-enabling BitLocker on $MountPoint with a recovery-password protector (no TPM protector was present before)." -Level 'INFO'
|
|
Enable-BitLocker -MountPoint $MountPoint -RecoveryPasswordProtector -SkipHardwareTest -ErrorAction Stop | Out-Null
|
|
}
|
|
|
|
$info = Get-BitLockerInfo -MountPoint $MountPoint
|
|
if (-not ($info.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' })) {
|
|
Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryPasswordProtector -ErrorAction Stop | Out-Null
|
|
$info = Get-BitLockerInfo -MountPoint $MountPoint
|
|
}
|
|
}
|
|
catch {
|
|
Write-Log -Message "Re-enabling BitLocker on $MountPoint failed: $_. You MUST re-encrypt $MountPoint manually." -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
$rp = $info.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
|
|
if ($rp) {
|
|
Write-Host ""
|
|
Write-Host "!!! NEW BITLOCKER RECOVERY PASSWORD - RECORD THIS NOW !!!" -ForegroundColor Red
|
|
Write-Host "Key protector ID : $($rp.KeyProtectorId)" -ForegroundColor Red
|
|
Write-Host "Recovery password : $($rp.RecoveryPassword)" -ForegroundColor Red
|
|
Write-Log -Message "New BitLocker recovery password on ${MountPoint}: ID $($rp.KeyProtectorId) = $($rp.RecoveryPassword)" -Level 'WARNING'
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host "Encryption has (re)started in the background on $MountPoint." -ForegroundColor Yellow
|
|
if ($OriginalProtectorTypes -and ($OriginalProtectorTypes | Where-Object { $_ -ne 'Tpm' -and $_ -ne 'RecoveryPassword' })) {
|
|
Write-Host "Original protectors also included: $($OriginalProtectorTypes -join ', ') - reconfigure any PIN/startup-key/AD-Entra escrow manually." -ForegroundColor Yellow
|
|
}
|
|
Write-Log -Message "Re-encryption of $MountPoint started (runs in the background)." -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Show-PreflightChecklist
|
|
# ============================================================================
|
|
function Show-PreflightChecklist {
|
|
param(
|
|
[Parameter(Mandatory)][int]$DiskNumber,
|
|
[Parameter(Mandatory)][string]$WinREStatusText,
|
|
[Parameter(Mandatory)][bool]$HasExistingRecovery,
|
|
[Parameter(Mandatory)][int]$AvailableMB,
|
|
[Parameter(Mandatory)][int]$RecoverySizeMB,
|
|
[Parameter()]$BitLockerInfo
|
|
)
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "PRE-FLIGHT CHECKLIST (already verified automatically)" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "[OK] Running as Administrator, required cmdlets/tools present"
|
|
Write-Host "[OK] System disk detected: Disk $DiskNumber"
|
|
Write-Host "[OK] WinRE status queried: $WinREStatusText"
|
|
if ($HasExistingRecovery) {
|
|
Write-Host "[OK] Existing Recovery partition detected (identity confirmed next)"
|
|
} else {
|
|
Write-Host "[--] No existing Recovery partition found - fresh build"
|
|
}
|
|
Write-Host "[OK] Free space check: $AvailableMB MB available / $RecoverySizeMB MB required"
|
|
if ($BitLockerInfo) {
|
|
if ($BitLockerInfo.ProtectionStatus -eq 'On') {
|
|
Write-Host "[!!] BitLocker on C: is $($BitLockerInfo.VolumeStatus), protection ON - will be suspended automatically for the destructive steps and resumed afterwards" -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host "[OK] BitLocker on C: is $($BitLockerInfo.VolumeStatus), protection $($BitLockerInfo.ProtectionStatus) - no suspend needed"
|
|
}
|
|
} else {
|
|
Write-Host "[--] BitLocker not present/not queryable on C:"
|
|
}
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host "Still needs YOUR manual confirmation:" -ForegroundColor Yellow
|
|
if ($HasExistingRecovery) {
|
|
Write-Host " - Identity of the detected Recovery partition"
|
|
}
|
|
Write-Host " - VM snapshot (VMware) / checkpoint (Hyper-V) state"
|
|
Write-Host " - Existence of a recent, successful backup"
|
|
Write-Host ""
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-ReAgentLogTail
|
|
# ============================================================================
|
|
function Get-ReAgentLogTail {
|
|
param([int]$Lines = 40)
|
|
|
|
$logPath = Join-Path -Path $env:WINDIR -ChildPath 'Logs\ReAgent\ReAgent.log'
|
|
if (-not (Test-Path -Path $logPath)) { return $null }
|
|
|
|
try {
|
|
return Get-Content -Path $logPath -Tail $Lines -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Write-Log -Message "Could not read ${logPath}: $_" -Level 'DEBUG'
|
|
return $null
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-WinREWimSizeMB
|
|
# ============================================================================
|
|
function Get-WinREWimSizeMB {
|
|
$candidatePaths = @(
|
|
(Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\Winre.wim')
|
|
)
|
|
foreach ($path in $candidatePaths) {
|
|
if (Test-Path -Path $path) {
|
|
try {
|
|
$bytes = (Get-Item -Path $path -Force -ErrorAction Stop).Length
|
|
return [math]::Ceiling($bytes / 1MB)
|
|
}
|
|
catch {
|
|
Write-Log -Message "Could not read size of ${path}: $_" -Level 'DEBUG'
|
|
}
|
|
}
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Get-RecommendedRecoverySizeMB
|
|
# ============================================================================
|
|
function Get-RecommendedRecoverySizeMB {
|
|
param([int]$FallbackMB = 2000)
|
|
|
|
$wimSizeMB = Get-WinREWimSizeMB
|
|
if (-not $wimSizeMB) { return $FallbackMB }
|
|
|
|
$recommended = $wimSizeMB + 400 + 100
|
|
return [math]::Max($recommended, $FallbackMB)
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Disable-WinRE
|
|
# ============================================================================
|
|
function Disable-WinRE {
|
|
Write-Log -Message "Disabling WinRE via reagentc /disable" -Level 'INFO'
|
|
|
|
$result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/disable')
|
|
Write-Log -Message "reagentc /disable output (exit $($result.ExitCode)): $($result.Output -join ' | ')" -Level 'DEBUG'
|
|
|
|
Start-Sleep -Seconds 2
|
|
$status = Get-WinREStatus
|
|
|
|
if ($status.Status -notmatch 'Disabled') {
|
|
Write-Log -Message "WinRE could not be disabled. Current status: $($status.Status)" -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "WinRE successfully disabled" -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Enable-WinRE
|
|
# ============================================================================
|
|
function Enable-WinRE {
|
|
Write-Log -Message "Enabling WinRE via reagentc /enable" -Level 'INFO'
|
|
|
|
$result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/enable')
|
|
Write-Log -Message "reagentc /enable output (exit $($result.ExitCode)): $($result.Output -join ' | ')" -Level 'DEBUG'
|
|
|
|
Start-Sleep -Seconds 2
|
|
$status = Get-WinREStatus
|
|
|
|
if ($status.Status -notmatch 'Enabled') {
|
|
Write-Log -Message "WinRE could not be enabled. Current status: $($status.Status)" -Level 'ERROR'
|
|
Write-Log -Message "Full reagentc output: `n$($status.RawOutput -join "`n")" -Level 'ERROR'
|
|
|
|
$reagentLog = Get-ReAgentLogTail -Lines 40
|
|
if ($reagentLog) {
|
|
Write-Log -Message "Tail of $env:WINDIR\Logs\ReAgent\ReAgent.log (often shows the REAL cause behind a misleading console message):`n$($reagentLog -join "`n")" -Level 'ERROR'
|
|
}
|
|
|
|
if (($result.Output -join ' ') -match 'BitLocker') {
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "REAGENTC REFUSED - CITES BITLOCKER" -ForegroundColor Red
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host @"
|
|
reagentc.exe refused to enable WinRE citing BitLocker Drive
|
|
Encryption on this volume - even with BitLocker protection
|
|
suspended/off. This is a documented reagentc limitation on some
|
|
builds: it can block whenever the volume carries ANY BitLocker
|
|
encryption at all, regardless of protector/suspend state, so
|
|
suspending protectors (what this script already tried) is not
|
|
always enough.
|
|
|
|
Known workaround - NOT automated here on purpose, because it
|
|
means fully decrypting and later re-encrypting C:, and only you
|
|
know which protectors / recovery-key escrow policy (TPM, PIN,
|
|
AD/Entra/MBAM) need restoring afterwards:
|
|
1. manage-bde -off C: (full decrypt - can take a while)
|
|
2. reagentc /enable
|
|
3. Re-enable BitLocker with your normal protector setup.
|
|
|
|
IMPORTANT: this exact console message is also known to be
|
|
misleading - the REAL cause can instead be an undersized
|
|
Recovery partition for winre.wim, or the new partition's ID/
|
|
attributes not yet recognized by reagentc, OR (v2.1) a gap
|
|
between C: and the Recovery partition if adjacency turns out to
|
|
matter. Check the ReAgent.log excerpt above (also in the log
|
|
file) before assuming it really is BitLocker.
|
|
"@
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "WinRE successfully enabled" -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Invoke-DiskpartScript
|
|
# ============================================================================
|
|
function Invoke-DiskpartScript {
|
|
param([Parameter(Mandatory)][string[]]$Commands)
|
|
|
|
$tempScript = Join-Path -Path $env:TEMP -ChildPath "diskpart_$([guid]::NewGuid().ToString('N')).txt"
|
|
$Commands | Set-Content -Path $tempScript -Encoding ASCII
|
|
|
|
Write-Log -Message "Running diskpart script: `n$($Commands -join "`n")" -Level 'DEBUG'
|
|
|
|
$result = Invoke-NativeCommand -FilePath 'diskpart.exe' -ArgumentList @('/s', $tempScript)
|
|
Write-Log -Message "diskpart output (exit $($result.ExitCode)): `n$($result.Output -join "`n")" -Level 'DEBUG'
|
|
|
|
Remove-Item -Path $tempScript -Force -ErrorAction SilentlyContinue
|
|
|
|
return $result.Output
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Remove-RecoveryPartition
|
|
# ============================================================================
|
|
function Remove-RecoveryPartition {
|
|
param(
|
|
[Parameter(Mandatory)][int]$DiskNumber,
|
|
[Parameter(Mandatory)][int]$PartitionNumber
|
|
)
|
|
|
|
Write-Log -Message "Removing Recovery Partition: Disk $DiskNumber / Partition $PartitionNumber" -Level 'WARNING'
|
|
|
|
$commands = @(
|
|
"select disk $DiskNumber"
|
|
"select partition $PartitionNumber"
|
|
"delete partition override"
|
|
"exit"
|
|
)
|
|
|
|
Invoke-DiskpartScript -Commands $commands | Out-Null
|
|
|
|
Start-Sleep -Seconds 2
|
|
$stillExists = Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.PartitionNumber -eq $PartitionNumber }
|
|
|
|
if ($stillExists) {
|
|
Write-Log -Message "Recovery Partition removal FAILED - partition still present" -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "Recovery Partition removed successfully" -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: New-RecoveryPartition
|
|
# v2.1 FIX: creates the Recovery partition at the TRUE END of the disk
|
|
# using an EXPLICIT -Offset computed from disk.Size, instead of letting
|
|
# New-Partition auto-place it. Auto-placement (no -Offset) puts a new
|
|
# partition at the START of the first available free extent - which,
|
|
# right after removing the old Recovery, is the space starting
|
|
# immediately after C: - NOT the end of the disk. That silently glued
|
|
# Recovery onto C: and pushed all remaining free space past it, to the
|
|
# true end of the disk, which is the opposite of this script's design
|
|
# intent (Recovery at the true end, C: expandable into the gap).
|
|
#
|
|
# No longer checks/enforces adjacency to C: at creation time - Recovery
|
|
# is intentionally allowed to have a gap in front of it here. Adjacency
|
|
# (if it matters to reagentc) is established afterwards, optionally, by
|
|
# expanding C: into that gap - see the "Expand C:?" step in MAIN, which
|
|
# now runs BEFORE Enable-WinRE.
|
|
# ============================================================================
|
|
function New-RecoveryPartition {
|
|
param(
|
|
[Parameter(Mandatory)][int]$DiskNumber,
|
|
[Parameter(Mandatory)][int]$RecoverySizeMB,
|
|
[Parameter(Mandatory)][string]$PartitionStyle,
|
|
[int]$EndBufferMB = 1
|
|
)
|
|
|
|
$sizeBytes = [uint64]$RecoverySizeMB * 1MB
|
|
$bufferBytes = [uint64]$EndBufferMB * 1MB
|
|
|
|
$disk = Get-Disk -Number $DiskNumber
|
|
$rawOffset = $disk.Size - $bufferBytes - $sizeBytes
|
|
if ($rawOffset -le 0) {
|
|
Write-Log -Message "Computed Recovery offset ($rawOffset) is not positive - disk is too small for a $RecoverySizeMB MB Recovery partition plus $EndBufferMB MB buffer." -Level 'ERROR'
|
|
return $null
|
|
}
|
|
$alignedOffset = [uint64]([math]::Floor($rawOffset / 1MB) * 1MB)
|
|
|
|
Write-Log -Message "Creating new Recovery Partition ($RecoverySizeMB MB) on Disk $DiskNumber at explicit end-of-disk offset $alignedOffset (disk size $($disk.Size) bytes, buffer $EndBufferMB MB)" -Level 'INFO'
|
|
|
|
try {
|
|
$newPartition = New-Partition -DiskNumber $DiskNumber -Size $sizeBytes -Offset $alignedOffset -AssignDriveLetter:$false -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Write-Log -Message "New-Partition at offset $alignedOffset failed: $_" -Level 'ERROR'
|
|
Write-Log -Message "This usually means the trailing free space no longer matches what the free-space check saw - re-run the script from Step 1 so it re-reads the current disk layout instead of continuing from a stale state." -Level 'ERROR'
|
|
return $null
|
|
}
|
|
|
|
# Sanity check: it must actually have landed at (or very near, after
|
|
# alignment rounding) the true end of the disk.
|
|
$diskEndSlack = 2MB
|
|
$partitionEndOffset = $newPartition.Offset + $newPartition.Size
|
|
if (($disk.Size - $partitionEndOffset) -gt ($bufferBytes + $diskEndSlack)) {
|
|
Write-Log -Message "New Recovery partition ends at $partitionEndOffset, but disk size is $($disk.Size) bytes - it does not appear to be at the true end of the disk. Removing it and aborting rather than leaving a stray partition behind." -Level 'ERROR'
|
|
try {
|
|
Remove-Partition -DiskNumber $DiskNumber -PartitionNumber $newPartition.PartitionNumber -Confirm:$false -ErrorAction Stop
|
|
Write-Log -Message "Removed the misplaced partition (was Partition $($newPartition.PartitionNumber))." -Level 'INFO'
|
|
}
|
|
catch {
|
|
Write-Log -Message "Could not remove the misplaced partition automatically: $_. Remove Partition $($newPartition.PartitionNumber) on Disk $DiskNumber manually before re-running." -Level 'ERROR'
|
|
}
|
|
return $null
|
|
}
|
|
|
|
try {
|
|
Format-Volume -Partition $newPartition -FileSystem NTFS -NewFileSystemLabel "Recovery" -Confirm:$false -Force -ErrorAction Stop | Out-Null
|
|
}
|
|
catch {
|
|
Write-Log -Message "Format-Volume failed: $_" -Level 'ERROR'
|
|
return $null
|
|
}
|
|
|
|
Write-Log -Message "New Recovery Partition created: Partition $($newPartition.PartitionNumber) at offset $($newPartition.Offset)" -Level 'SUCCESS'
|
|
|
|
$allParts = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset
|
|
if ($allParts[-1].PartitionNumber -ne $newPartition.PartitionNumber) {
|
|
Write-Log -Message "WARNING: newly created Recovery partition is NOT the last partition on the disk. Layout may be unexpected." -Level 'WARNING'
|
|
}
|
|
|
|
$commands = @("select disk $DiskNumber", "select partition $($newPartition.PartitionNumber)")
|
|
|
|
if ($PartitionStyle -eq 'GPT') {
|
|
$commands += "set id=$Script:RecoveryGptType"
|
|
$commands += "gpt attributes=$Script:RecoveryGptAttrs"
|
|
}
|
|
else {
|
|
$commands += "set id=$Script:RecoveryMbrType"
|
|
}
|
|
$commands += "exit"
|
|
|
|
Invoke-DiskpartScript -Commands $commands | Out-Null
|
|
|
|
Start-Sleep -Seconds 2
|
|
$verifyPartition = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $newPartition.PartitionNumber
|
|
|
|
Write-Log -Message "Recovery Partition type configured (Style: $PartitionStyle)" -Level 'SUCCESS'
|
|
|
|
return $verifyPartition
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Resize-SystemPartition
|
|
# Expands C: into whatever contiguous free space remains (the gap
|
|
# between C: and the already-created, true-end-of-disk Recovery
|
|
# partition). Get-PartitionSupportedSize naturally stops right before
|
|
# Recovery, so no manual reserve math is needed.
|
|
# ============================================================================
|
|
function Resize-SystemPartition {
|
|
param([Parameter(Mandatory)][int]$DiskNumber)
|
|
|
|
$before = Get-Partition -DiskNumber $DiskNumber | Where-Object { $_.DriveLetter -eq 'C' }
|
|
$beforeSizeGB = [math]::Round($before.Size / 1GB, 2)
|
|
|
|
$supported = Get-PartitionSupportedSize -DiskNumber $DiskNumber -PartitionNumber $before.PartitionNumber
|
|
$targetSize = $supported.SizeMax
|
|
|
|
if ($targetSize -le $before.Size) {
|
|
Write-Log -Message "C: is already at its maximum possible size - nothing to expand" -Level 'WARNING'
|
|
return $true
|
|
}
|
|
|
|
Write-Log -Message "Resizing C: from $([math]::Round($before.Size/1GB,2)) GB to $([math]::Round($targetSize/1GB,2)) GB" -Level 'INFO'
|
|
|
|
try {
|
|
Resize-Partition -DiskNumber $DiskNumber -PartitionNumber $before.PartitionNumber -Size $targetSize -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Write-Log -Message "Resize-Partition failed: $_" -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
Start-Sleep -Seconds 2
|
|
$after = Get-Partition -DriveLetter C
|
|
$afterSizeGB = [math]::Round($after.Size / 1GB, 2)
|
|
|
|
Write-Host ""
|
|
Write-Host "C: before: $beforeSizeGB GB"
|
|
Write-Host "C: after : $afterSizeGB GB"
|
|
Write-Host "Increase : +$([math]::Round($afterSizeGB - $beforeSizeGB, 2)) GB"
|
|
|
|
if ($after.Size -le $before.Size) {
|
|
Write-Log -Message "C: partition size did not increase as expected" -Level 'ERROR'
|
|
return $false
|
|
}
|
|
|
|
Write-Log -Message "C: successfully expanded to $afterSizeGB GB" -Level 'SUCCESS'
|
|
return $true
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Test-FinalState
|
|
# ============================================================================
|
|
function Test-FinalState {
|
|
param(
|
|
[Parameter(Mandatory)][int]$DiskNumber,
|
|
[Parameter(Mandatory)]$RecoveryPartition
|
|
)
|
|
|
|
$results = [ordered]@{}
|
|
|
|
$disk = Get-Disk -Number $DiskNumber
|
|
$results['Disk online/healthy'] = ($disk.OperationalStatus -eq 'Online' -and $disk.HealthStatus -eq 'Healthy')
|
|
|
|
$c = Get-Partition -DriveLetter C
|
|
$cVolume = Get-Volume -DriveLetter C
|
|
$results['C: exists and NTFS'] = ($cVolume.FileSystem -eq 'NTFS')
|
|
|
|
$recovery = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $RecoveryPartition.PartitionNumber
|
|
$results['Recovery exists'] = [bool]$recovery
|
|
$results['Recovery has no drive letter'] = (-not $recovery.DriveLetter)
|
|
|
|
$allPartitions = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset
|
|
$results['Recovery is last partition'] = ($allPartitions[-1].PartitionNumber -eq $recovery.PartitionNumber)
|
|
|
|
$winre = Get-WinREStatus
|
|
$results['WinRE enabled'] = ($winre.Status -match 'Enabled')
|
|
$results['WinRE location matches new Recovery'] = (
|
|
$winre.Location -and
|
|
$winre.Location.DiskNumber -eq $DiskNumber -and
|
|
$winre.Location.PartitionNumber -eq $recovery.PartitionNumber
|
|
)
|
|
|
|
return $results
|
|
}
|
|
|
|
# ============================================================================
|
|
# FUNCTION: Show-FinalSummary
|
|
# ============================================================================
|
|
function Show-FinalSummary {
|
|
param(
|
|
[Parameter(Mandatory)][hashtable]$Results,
|
|
[Parameter(Mandatory)][double]$CSizeBeforeGB,
|
|
[Parameter(Mandatory)][double]$CSizeAfterGB,
|
|
[Parameter(Mandatory)][int]$RecoverySizeMB,
|
|
[Parameter(Mandatory)][bool]$CExpanded,
|
|
[Parameter(Mandatory)][int]$RemainingFreeMB
|
|
)
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "FINAL VALIDATION" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
|
|
$allOk = $true
|
|
foreach ($key in $Results.Keys) {
|
|
$status = if ($Results[$key]) { "[OK]" } else { $allOk = $false; "[FAIL]" }
|
|
$color = if ($Results[$key]) { 'Green' } else { 'Red' }
|
|
Write-Host ("{0} {1}" -f $status, $key) -ForegroundColor $color
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "RESULT" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "C: BEFORE : $CSizeBeforeGB GB"
|
|
Write-Host "C: AFTER : $CSizeAfterGB GB"
|
|
if ($CExpanded) {
|
|
Write-Host "GAIN : +$([math]::Round($CSizeAfterGB - $CSizeBeforeGB, 2)) GB"
|
|
} else {
|
|
Write-Host "GAIN : C: was NOT expanded (skipped on request)"
|
|
Write-Host "REMAINING : ~$RemainingFreeMB MB free between C: and Recovery (unallocated)"
|
|
}
|
|
Write-Host ""
|
|
Write-Host "Recovery Partition:"
|
|
Write-Host "Size : $RecoverySizeMB MB"
|
|
Write-Host "Position : Last partition"
|
|
Write-Host ""
|
|
|
|
if ($allOk) {
|
|
Write-Host "STATUS: SUCCESS" -ForegroundColor Green
|
|
Write-Log -Message "FINAL STATUS: SUCCESS" -Level 'SUCCESS'
|
|
} else {
|
|
Write-Host "STATUS: FAILED" -ForegroundColor Red
|
|
Write-Log -Message "FINAL STATUS: FAILED" -Level 'ERROR'
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host "POST-ACTION VM CHECK" -ForegroundColor Yellow
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host @"
|
|
If you created a VMware snapshot or Hyper-V checkpoint before
|
|
this operation, do NOT keep it longer than necessary.
|
|
|
|
Verify:
|
|
- Windows boots normally
|
|
- C: has the expected size
|
|
- WinRE is Enabled
|
|
- Recovery partition is present
|
|
- Backup is healthy
|
|
|
|
Only after validation should the temporary snapshot
|
|
be removed according to your VMware/backup procedure.
|
|
"@
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
}
|
|
|
|
# ============================================================================
|
|
# MAIN EXECUTION
|
|
# ============================================================================
|
|
try {
|
|
if (-not (Test-Path -Path $LogPath)) {
|
|
New-Item -Path $LogPath -ItemType Directory -Force | Out-Null
|
|
}
|
|
$timestampForFile = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
$Script:LogFile = Join-Path -Path $LogPath -ChildPath "WinRE-Resize-$timestampForFile-$env:COMPUTERNAME.log"
|
|
$Script:TranscriptFile = Join-Path -Path $LogPath -ChildPath "WinRE-Resize-$timestampForFile-$env:COMPUTERNAME.transcript.log"
|
|
|
|
try { Start-Transcript -Path $Script:TranscriptFile -Force | Out-Null } catch {
|
|
Write-Host "WARNING: could not start transcript ($_)" -ForegroundColor Yellow
|
|
}
|
|
|
|
Write-Banner
|
|
Write-Log -Message "=== WinRE-Resize2.ps1 v$Script:ToolVersion started ===" -Level 'INFO'
|
|
Write-Log -Message "Parameters: AuditOnly=$AuditOnly RecoverySizeMB=$RecoverySizeMB DiskEndBufferMB=$DiskEndBufferMB LogPath=$LogPath" -Level 'INFO'
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 1 - DISCOVERY
|
|
# ------------------------------------------------------------------
|
|
|
|
Write-Step -Description "Checking administrator privileges" -Status 'START'
|
|
if (-not (Test-IsAdministrator)) {
|
|
Write-Step -Description "Checking administrator privileges" -Status 'FAIL'
|
|
Stop-Safely -Reason "Script must be run as Administrator. Please re-launch PowerShell elevated."
|
|
}
|
|
Write-Step -Description "Checking administrator privileges" -Status 'OK'
|
|
|
|
foreach ($cmd in @('Get-Disk','Get-Partition','Get-Volume','Resize-Partition','New-Partition','Format-Volume')) {
|
|
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
|
Stop-Safely -Reason "Required cmdlet '$cmd' is not available on this system."
|
|
}
|
|
}
|
|
foreach ($exe in @('reagentc.exe','diskpart.exe')) {
|
|
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
|
|
Stop-Safely -Reason "Required executable '$exe' was not found in PATH."
|
|
}
|
|
}
|
|
|
|
Write-Step -Description "Detecting system disk" -Status 'START'
|
|
$sysDiskInfo = Get-SystemDisk
|
|
$diskNumber = $sysDiskInfo.Disk.Number
|
|
$cPartition = $sysDiskInfo.CPartition
|
|
$cSizeBeforeGB = [math]::Round($cPartition.Size / 1GB, 2)
|
|
Write-Step -Description "Detecting system disk" -Status 'OK'
|
|
|
|
$partitions = Get-DiskLayout -DiskNumber $diskNumber
|
|
|
|
Write-Step -Description "Checking WinRE" -Status 'START'
|
|
$winREStatus = Get-WinREStatus
|
|
Write-Host ""
|
|
Write-Host "WinRE Status : $($winREStatus.Status)"
|
|
if ($winREStatus.Location) {
|
|
Write-Host "WinRE Location : Disk $($winREStatus.Location.DiskNumber) / Partition $($winREStatus.Location.PartitionNumber)"
|
|
}
|
|
Write-Step -Description "Checking WinRE" -Status 'OK'
|
|
|
|
Write-Step -Description "Detecting Recovery partition" -Status 'START'
|
|
$recoveryPartition = Get-RecoveryPartition -Disk $sysDiskInfo.Disk -Partitions $partitions -CPartition $cPartition -WinREStatus $winREStatus
|
|
$hasExistingRecovery = [bool]$recoveryPartition
|
|
if ($hasExistingRecovery) {
|
|
Write-Step -Description "Detecting Recovery partition" -Status 'OK'
|
|
} else {
|
|
Write-Step -Description "Detecting Recovery partition" -Status 'SKIP'
|
|
Write-Host "No existing Recovery partition found - a new one will be created from scratch." -ForegroundColor Yellow
|
|
}
|
|
|
|
Write-Step -Description "Checking BitLocker status" -Status 'START'
|
|
$bitlockerInfo = Get-BitLockerInfo -MountPoint 'C:'
|
|
if ($bitlockerInfo) {
|
|
Write-Host ""
|
|
Write-Host "BitLocker on C: : $($bitlockerInfo.VolumeStatus) / Protection $($bitlockerInfo.ProtectionStatus)"
|
|
if ($bitlockerInfo.ProtectionStatus -eq 'On') {
|
|
Write-Host "Protection will be suspended automatically around the WinRE/partition steps and resumed afterwards." -ForegroundColor Yellow
|
|
}
|
|
Write-Step -Description "Checking BitLocker status" -Status 'OK'
|
|
} else {
|
|
Write-Step -Description "Checking BitLocker status" -Status 'SKIP'
|
|
Write-Log -Message "BitLocker not present/not queryable on C: - nothing to suspend." -Level 'INFO'
|
|
}
|
|
|
|
Write-Step -Description "Checking Recovery partition sizing" -Status 'START'
|
|
$winreWimSizeMB = Get-WinREWimSizeMB
|
|
$recommendedRecoveryMB = Get-RecommendedRecoverySizeMB
|
|
if ($winreWimSizeMB) {
|
|
Write-Host ""
|
|
Write-Host "Current Winre.wim size : ~$winreWimSizeMB MB"
|
|
Write-Host "Recommended minimum : $recommendedRecoveryMB MB (includes reagentc's working-space buffer + headroom)"
|
|
}
|
|
if ($RecoverySizeMB -lt $recommendedRecoveryMB) {
|
|
Write-Step -Description "Checking Recovery partition sizing" -Status 'FAIL'
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host "RECOVERY PARTITION SIZE WARNING" -ForegroundColor Yellow
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
Write-Host @"
|
|
reagentc /enable does NOT error out when the Recovery partition
|
|
is too small - it silently falls back to storing WinRE inside
|
|
C: itself instead, even though the partition otherwise looks
|
|
perfectly valid (correct type, no drive letter, adjacent to C:).
|
|
This has been observed directly on this system.
|
|
|
|
Requested Recovery partition size : $RecoverySizeMB MB
|
|
Recommended minimum for this system: $recommendedRecoveryMB MB
|
|
"@
|
|
Write-Host ("=" * 60) -ForegroundColor Yellow
|
|
if (-not (Confirm-YesNo -Prompt "Continue anyway with a $RecoverySizeMB MB Recovery partition (real risk of the same silent fallback)?")) {
|
|
Stop-Safely -Reason "Aborted by user - re-run with -RecoverySizeMB $recommendedRecoveryMB (or more)."
|
|
}
|
|
} else {
|
|
Write-Step -Description "Checking Recovery partition sizing" -Status 'OK'
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 2 - VALIDATION
|
|
# ------------------------------------------------------------------
|
|
|
|
Write-Step -Description "Checking free space" -Status 'START'
|
|
|
|
if ($hasExistingRecovery) {
|
|
if (-not (Test-DiskLayout -Partitions $partitions -CPartition $cPartition -RecoveryPartition $recoveryPartition)) {
|
|
Write-Step -Description "Checking free space" -Status 'FAIL'
|
|
Write-Log -Message "WARNING: Recovery partition is not directly behind C:. Automatic procedure cannot safely continue." -Level 'ERROR'
|
|
Stop-Safely -Reason "Recovery partition is not directly behind C:. Aborting for safety."
|
|
}
|
|
}
|
|
|
|
$trailingFreeMB = Get-TrailingFreeSpaceMB -DiskNumber $diskNumber
|
|
$existingRecoveryMB = if ($hasExistingRecovery) { [math]::Round($recoveryPartition.Size / 1MB, 0) } else { 0 }
|
|
$availableMB = $trailingFreeMB + $existingRecoveryMB
|
|
|
|
Write-Host ""
|
|
Write-Host "FREE SPACE CHECK" -ForegroundColor Cyan
|
|
Write-Host "Existing Recovery size : $existingRecoveryMB MB"
|
|
Write-Host "Trailing unallocated space: $trailingFreeMB MB"
|
|
Write-Host "Total available : $availableMB MB"
|
|
Write-Host "Required for new Recovery : $RecoverySizeMB MB"
|
|
|
|
if ($availableMB -lt $RecoverySizeMB) {
|
|
Write-Step -Description "Checking free space" -Status 'FAIL'
|
|
Stop-Safely -Reason "Insufficient space: only $availableMB MB available (existing Recovery + trailing free space), need at least $RecoverySizeMB MB. Free up space manually (e.g. shrink C: further) and re-run."
|
|
}
|
|
Write-Step -Description "Checking free space" -Status 'OK'
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "CURRENT SYSTEM STATE" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "Computer : $env:COMPUTERNAME"
|
|
Write-Host "System Disk : $diskNumber"
|
|
Write-Host "Partition Style : $($sysDiskInfo.Disk.PartitionStyle)"
|
|
Write-Host "C: Size : $cSizeBeforeGB GB"
|
|
Write-Host "WinRE Status : $($winREStatus.Status)"
|
|
if ($hasExistingRecovery) {
|
|
Write-Host "Recovery Part# : $($recoveryPartition.PartitionNumber)"
|
|
Write-Host "Recovery Size : $existingRecoveryMB MB"
|
|
} else {
|
|
Write-Host "Recovery Part# : (none - will be created fresh)"
|
|
}
|
|
|
|
if ($AuditOnly) {
|
|
Write-Host ""
|
|
Write-Host "AUDIT ONLY" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host "Planned actions (NOT executed):"
|
|
$n = 1
|
|
if ($hasExistingRecovery) {
|
|
Write-Host " $n. Disable WinRE"; $n++
|
|
Write-Host " $n. Delete old Recovery Partition $($recoveryPartition.PartitionNumber) on Disk $diskNumber"; $n++
|
|
}
|
|
Write-Host " $n. Create new Recovery Partition ($RecoverySizeMB MB) at the true end of the disk"; $n++
|
|
Write-Host " $n. Ask whether to expand C: into the remaining free space (interactive, optional)"; $n++
|
|
Write-Host " $n. Configure Recovery Partition type ($($sysDiskInfo.Disk.PartitionStyle)) and re-enable WinRE"
|
|
Write-Host ""
|
|
Write-Host "No changes have been made."
|
|
Write-Log -Message "Audit-only run completed. No changes made." -Level 'SUCCESS'
|
|
Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
|
|
exit 0
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 3 - USER CONFIRMATION
|
|
# ------------------------------------------------------------------
|
|
|
|
Show-PreflightChecklist -DiskNumber $diskNumber -WinREStatusText $winREStatus.Status `
|
|
-HasExistingRecovery $hasExistingRecovery -AvailableMB $availableMB -RecoverySizeMB $RecoverySizeMB `
|
|
-BitLockerInfo $bitlockerInfo
|
|
|
|
Write-Step -Description "User confirmation" -Status 'START'
|
|
|
|
if ($hasExistingRecovery) {
|
|
if (-not (Confirm-RecoveryPartition -DiskNumber $diskNumber -RecoveryPartition $recoveryPartition -CPartition $cPartition)) {
|
|
Write-Step -Description "User confirmation" -Status 'FAIL'
|
|
Stop-Safely -Reason "User did not confirm the detected Recovery partition."
|
|
}
|
|
}
|
|
|
|
if (-not (Confirm-Snapshot)) {
|
|
Write-Step -Description "User confirmation" -Status 'FAIL'
|
|
Stop-Safely -Reason "User did not confirm VM snapshot/checkpoint verification."
|
|
}
|
|
|
|
if (-not (Confirm-Backup)) {
|
|
Write-Step -Description "User confirmation" -Status 'FAIL'
|
|
Stop-Safely -Reason "User did not confirm existence of a recent successful backup."
|
|
}
|
|
|
|
Write-Step -Description "User confirmation" -Status 'OK'
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 4 - DESTRUCTIVE OPERATION (only if an old Recovery exists)
|
|
# ------------------------------------------------------------------
|
|
|
|
if ($bitlockerInfo -and $bitlockerInfo.ProtectionStatus -eq 'On') {
|
|
Write-Step -Description "Suspending BitLocker" -Status 'START'
|
|
if (Suspend-BitLockerProtection -MountPoint 'C:') {
|
|
$Script:BitLockerSuspended = $true
|
|
Write-Step -Description "Suspending BitLocker" -Status 'OK'
|
|
} else {
|
|
Write-Step -Description "Suspending BitLocker" -Status 'FAIL'
|
|
Stop-Safely -Reason "Could not suspend BitLocker protection on C:. Aborting before touching WinRE/partitions to avoid a BitLocker recovery prompt on next boot."
|
|
}
|
|
}
|
|
|
|
Write-Step -Description "Disabling WinRE" -Status 'START'
|
|
if ($winREStatus.Status -match 'Enabled') {
|
|
if (-not (Disable-WinRE)) {
|
|
Write-Step -Description "Disabling WinRE" -Status 'FAIL'
|
|
Stop-Safely -Reason "WinRE could not be disabled. Cannot safely proceed."
|
|
}
|
|
Write-Step -Description "Disabling WinRE" -Status 'OK'
|
|
} else {
|
|
Write-Step -Description "Disabling WinRE" -Status 'SKIP'
|
|
Write-Log -Message "WinRE already disabled - nothing to do." -Level 'INFO'
|
|
}
|
|
|
|
Write-Step -Description "Removing old Recovery partition" -Status 'START'
|
|
if ($hasExistingRecovery) {
|
|
$finalCheckPartitions = Get-Partition -DiskNumber $diskNumber
|
|
$finalRecovery = $finalCheckPartitions | Where-Object { $_.PartitionNumber -eq $recoveryPartition.PartitionNumber }
|
|
$finalC = $finalCheckPartitions | Where-Object { $_.DriveLetter -eq 'C' }
|
|
|
|
if (-not $finalRecovery -or -not $finalC) {
|
|
Write-Step -Description "Removing old Recovery partition" -Status 'FAIL'
|
|
Stop-Safely -Reason "Partition table changed unexpectedly before destructive step. Aborting."
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "FINAL DESTRUCTIVE CHECK" -ForegroundColor Red
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "System Disk : Disk $diskNumber ($($sysDiskInfo.Disk.FriendlyName))"
|
|
Write-Host "C: : Disk $diskNumber / Partition $($finalC.PartitionNumber)"
|
|
Write-Host "Recovery to DELETE: Disk $diskNumber / Partition $($finalRecovery.PartitionNumber)"
|
|
Write-Host "Size : $([math]::Round($finalRecovery.Size/1MB,0)) MB"
|
|
Write-Host ""
|
|
Write-Host "!!! DESTRUCTIVE OPERATION !!!" -ForegroundColor Red
|
|
Write-Host "This operation is irreversible without backup. The script will NOT delete C:."
|
|
Write-Host ""
|
|
$typed = Read-Host "Type exactly DELETE to continue"
|
|
if ($typed -cne 'DELETE') {
|
|
Write-Step -Description "Removing old Recovery partition" -Status 'FAIL'
|
|
Stop-Safely -Reason "User did not type DELETE exactly. Destructive operation aborted."
|
|
}
|
|
|
|
if (-not (Remove-RecoveryPartition -DiskNumber $diskNumber -PartitionNumber $finalRecovery.PartitionNumber)) {
|
|
Write-Step -Description "Removing old Recovery partition" -Status 'FAIL'
|
|
Stop-Safely -Reason "Recovery partition removal failed or could not be verified."
|
|
}
|
|
Write-Step -Description "Removing old Recovery partition" -Status 'OK'
|
|
} else {
|
|
Write-Step -Description "Removing old Recovery partition" -Status 'SKIP'
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 5 - REBUILD (Recovery at true end FIRST, then optional C:
|
|
# expansion, THEN enable WinRE)
|
|
# ------------------------------------------------------------------
|
|
|
|
$trailingFreeMB = Get-TrailingFreeSpaceMB -DiskNumber $diskNumber
|
|
if ($trailingFreeMB -lt ($RecoverySizeMB + $DiskEndBufferMB)) {
|
|
Stop-Safely -Reason "After removing the old Recovery partition, only $trailingFreeMB MB is free at the end of the disk - not enough for a $RecoverySizeMB MB Recovery partition plus the $DiskEndBufferMB MB safety buffer."
|
|
}
|
|
|
|
# Informational only (v2.1): with Recovery now placed at the true end
|
|
# of the disk via explicit offset, a partition sitting directly after
|
|
# C: no longer blocks Recovery creation - it just means there will be
|
|
# no gap for C: to expand into later. We warn instead of aborting.
|
|
$cPartitionNow = Get-Partition -DriveLetter C
|
|
$cEndOffsetNow = $cPartitionNow.Offset + $cPartitionNow.Size
|
|
$alignmentSlackNow = 1MB
|
|
$adjacentPartition = Get-Partition -DiskNumber $diskNumber | Where-Object {
|
|
$_.PartitionNumber -ne $cPartitionNow.PartitionNumber -and
|
|
$_.Offset -ge $cEndOffsetNow -and
|
|
($_.Offset - $cEndOffsetNow) -lt $alignmentSlackNow
|
|
}
|
|
if ($adjacentPartition) {
|
|
Write-Log -Message "NOTE: Partition $($adjacentPartition.PartitionNumber) ($([math]::Round($adjacentPartition.Size/1MB,0)) MB) already sits directly after C:. This will limit or eliminate the space available to later expand C: into." -Level 'WARNING'
|
|
}
|
|
|
|
Write-Step -Description "Creating Recovery partition" -Status 'START'
|
|
$newRecovery = New-RecoveryPartition -DiskNumber $diskNumber -RecoverySizeMB $RecoverySizeMB -PartitionStyle $sysDiskInfo.Disk.PartitionStyle -EndBufferMB $DiskEndBufferMB
|
|
if (-not $newRecovery) {
|
|
Write-Step -Description "Creating Recovery partition" -Status 'FAIL'
|
|
Stop-Safely -Reason "Failed to create the new Recovery partition. WinRE remains disabled - manual intervention required."
|
|
}
|
|
Write-Step -Description "Creating Recovery partition" -Status 'OK'
|
|
|
|
# --- Interactive: expand C: into whatever remains, BEFORE enabling WinRE ---
|
|
$cNow = Get-Partition -DriveLetter C
|
|
$recoveryNow = Get-Partition -DiskNumber $diskNumber -PartitionNumber $newRecovery.PartitionNumber
|
|
$gapBytes = $recoveryNow.Offset - ($cNow.Offset + $cNow.Size)
|
|
$gapMB = [math]::Max(0, [math]::Round($gapBytes / 1MB, 0))
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "EXPAND C: ?" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60) -ForegroundColor Cyan
|
|
Write-Host "Free space currently sitting between C: and the new Recovery partition: ~$gapMB MB"
|
|
|
|
$cExpanded = $false
|
|
if ($gapMB -lt 1) {
|
|
Write-Host "No meaningful free space to expand into - skipping." -ForegroundColor Yellow
|
|
}
|
|
elseif (Confirm-YesNo -Prompt "Do you want to expand C: to fill this space now?") {
|
|
Write-Step -Description "Expanding C:" -Status 'START'
|
|
if (Resize-SystemPartition -DiskNumber $diskNumber) {
|
|
$cExpanded = $true
|
|
Write-Step -Description "Expanding C:" -Status 'OK'
|
|
} else {
|
|
Write-Step -Description "Expanding C:" -Status 'FAIL'
|
|
Write-Log -Message "C: expansion failed - Recovery partition is unaffected. You can retry expanding C: manually via Disk Management. Proceeding to enable WinRE with the gap still in place." -Level 'WARNING'
|
|
}
|
|
}
|
|
else {
|
|
Write-Log -Message "User chose not to expand C: now. Remaining free space: ~$gapMB MB. If reagentc requires Recovery to be strictly adjacent to C:, leaving this gap in place could cause WinRE enable to fail or silently fall back to storing WinRE on C: - watch the next step's result." -Level 'WARNING'
|
|
}
|
|
|
|
# Step: Configure / enable WinRE (runs AFTER the expand-C: decision,
|
|
# so that if the user chose to expand, C: and Recovery are adjacent
|
|
# by the time reagentc runs).
|
|
Write-Step -Description "Configuring WinRE" -Status 'START'
|
|
$winREEnabled = Enable-WinRE
|
|
$bitlockerDecryptedByUs = $false
|
|
$bitlockerProtectorTypesBeforeDecrypt = @()
|
|
|
|
if (-not $winREEnabled) {
|
|
$currentBitlockerInfo = Get-BitLockerInfo -MountPoint 'C:'
|
|
if ($currentBitlockerInfo -and $currentBitlockerInfo.VolumeStatus -ne 'FullyDecrypted') {
|
|
Write-Log -Message "reagentc /enable failed while BitLocker encryption is still present on C: - offering the full-decrypt workaround." -Level 'WARNING'
|
|
$workaround = Invoke-BitLockerDecryptWorkaround -MountPoint 'C:'
|
|
if ($workaround.Success) {
|
|
$bitlockerDecryptedByUs = $true
|
|
$bitlockerProtectorTypesBeforeDecrypt = $workaround.ProtectorTypes
|
|
$winREEnabled = Enable-WinRE
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not $winREEnabled) {
|
|
Write-Step -Description "Configuring WinRE" -Status 'FAIL'
|
|
if ($bitlockerDecryptedByUs) {
|
|
Write-Log -Message "WinRE still could not be enabled even after fully decrypting C: - re-encrypting before aborting." -Level 'ERROR'
|
|
Invoke-BitLockerReencrypt -MountPoint 'C:' -OriginalProtectorTypes $bitlockerProtectorTypesBeforeDecrypt | Out-Null
|
|
}
|
|
Stop-Safely -Reason "Failed to enable WinRE after creating the new Recovery partition. Manual intervention required."
|
|
}
|
|
Write-Step -Description "Configuring WinRE" -Status 'OK'
|
|
|
|
if ($bitlockerDecryptedByUs) {
|
|
Invoke-BitLockerReencrypt -MountPoint 'C:' -OriginalProtectorTypes $bitlockerProtectorTypesBeforeDecrypt | Out-Null
|
|
}
|
|
|
|
if ($Script:BitLockerSuspended) {
|
|
Write-Step -Description "Resuming BitLocker" -Status 'START'
|
|
Resume-BitLockerProtection -MountPoint 'C:'
|
|
$Script:BitLockerSuspended = $false
|
|
Write-Step -Description "Resuming BitLocker" -Status 'OK'
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 6 - FINAL VALIDATION
|
|
# ------------------------------------------------------------------
|
|
|
|
Write-Step -Description "Final validation" -Status 'START'
|
|
$results = Test-FinalState -DiskNumber $diskNumber -RecoveryPartition $newRecovery
|
|
$cSizeAfterGB = [math]::Round((Get-Partition -DriveLetter C).Size / 1GB, 2)
|
|
$cFinal = Get-Partition -DriveLetter C
|
|
$recoveryFinal = Get-Partition -DiskNumber $diskNumber -PartitionNumber $newRecovery.PartitionNumber
|
|
$remainingGapMB = [math]::Max(0, [math]::Round(($recoveryFinal.Offset - ($cFinal.Offset + $cFinal.Size)) / 1MB, 0))
|
|
|
|
$stepStatus = if ($results.Values -notcontains $false) { 'OK' } else { 'FAIL' }
|
|
Write-Step -Description "Final validation" -Status $stepStatus
|
|
|
|
if ($results['WinRE enabled'] -and -not $results['WinRE location matches new Recovery']) {
|
|
Write-Host ""
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "WINRE IS ENABLED BUT NOT USING THE RECOVERY PARTITION" -ForegroundColor Red
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host @"
|
|
reagentc reported success, but WinRE is actually stored inside
|
|
C: itself rather than on the dedicated Recovery partition. This
|
|
can happen when reagentc's own partition-size check silently
|
|
rejects the Recovery partition and falls back to C: without
|
|
raising an error, OR (v2.1) if adjacency to C: turns out to be
|
|
required and a gap was left between C: and Recovery.
|
|
|
|
Current Winre.wim size : $(if ($winreWimSizeMB) { "~$winreWimSizeMB MB" } else { "unknown" })
|
|
Recovery partition size: $RecoverySizeMB MB
|
|
Recommended minimum : $recommendedRecoveryMB MB
|
|
Gap between C: and Recovery at final check: ~$remainingGapMB MB
|
|
|
|
If the partition size looks adequate above, check the ReAgent.log
|
|
excerpt below (also written to the script log) for the real reason.
|
|
"@
|
|
$reagentLogNow = Get-ReAgentLogTail -Lines 40
|
|
if ($reagentLogNow) {
|
|
Write-Log -Message "Tail of $env:WINDIR\Logs\ReAgent\ReAgent.log at final validation (WinRE enabled but not using the Recovery partition):`n$($reagentLogNow -join "`n")" -Level 'ERROR'
|
|
Write-Host ($reagentLogNow -join "`n")
|
|
}
|
|
Write-Host ("=" * 60) -ForegroundColor Red
|
|
Write-Host "If a gap remains, re-run and choose to expand C: this time so C: and Recovery are adjacent before WinRE is enabled." -ForegroundColor Yellow
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# PHASE 7 - FINAL REPORT
|
|
# ------------------------------------------------------------------
|
|
Show-FinalSummary -Results $results -CSizeBeforeGB $cSizeBeforeGB -CSizeAfterGB $cSizeAfterGB -RecoverySizeMB $RecoverySizeMB -CExpanded $cExpanded -RemainingFreeMB $remainingGapMB
|
|
|
|
Write-Log -Message "=== WinRE-Resize2.ps1 finished ===" -Level 'INFO'
|
|
Write-Host ""
|
|
Write-Host "Log file: $Script:LogFile"
|
|
|
|
try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
|
|
if ($results.Values -contains $false) { exit 1 } else { exit 0 }
|
|
}
|
|
catch {
|
|
if ($Script:BitLockerSuspended) {
|
|
Write-Log -Message "Unhandled exception while BitLocker was suspended - attempting to resume protection." -Level 'WARNING'
|
|
Resume-BitLockerProtection -MountPoint 'C:'
|
|
$Script:BitLockerSuspended = $false
|
|
}
|
|
|
|
Write-Log -Message "UNHANDLED EXCEPTION: $_" -Level 'ERROR'
|
|
Write-Log -Message "Stack trace: $($_.ScriptStackTrace)" -Level 'ERROR'
|
|
Write-Host ""
|
|
Write-Host "UNSAFE / AMBIGUOUS STATE" -ForegroundColor Red
|
|
Write-Host "An unexpected error occurred. No further changes will be made." -ForegroundColor Red
|
|
Write-Host "Error: $_" -ForegroundColor Red
|
|
if ($Script:LogFile) { Write-Host "Log: $Script:LogFile" -ForegroundColor Red }
|
|
try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
|
|
exit 1
|
|
} |