diff --git a/README.md b/README.md index 6e7e5b8..f3e3557 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ Everything above produces one screen, and nothing on disk has changed when it ap =============================================================================== * Shrink Drive D (ALERT) by 199 GB to free up space -* Create 199 GB Dev Drive on Disk 1 (CT4000P3PSSD8) using ReFS +* Create 249 GB Dev Drive on Disk 1 (CT4000P3PSSD8) using ReFS + 50 GB of unallocated space already sits next to drive D and will be taken + as well, so the Dev Drive comes out 249 GB rather than the 199 GB being freed. * Name the Dev Drive Projects * Skip BitLocker encryption * Enable ReFS deduplication and ZSTD compression, level 2 @@ -196,6 +198,7 @@ In compress-only mode there is no weekly maintenance job and no duration or CPU ## Troubleshooting - **Windows will not shrink the volume far enough.** Shrinking stops at the last written file block, so fragmentation limits it regardless of free space. The script shows the real limit Windows reports and suggests a third-party tool where that is not enough +- **Shrink mode takes the whole free run behind the drive, and says so before you agree.** The volume gives up exactly the amount you asked to free, and the Dev Drive then fills everything unallocated immediately behind it - the space just freed plus anything already sitting there. The plan names the resulting size and where the extra came from - or, on the rare drive whose limits Windows will not report, says the size shown is only a floor. Unallocated space elsewhere on the disk is never touched - **The drive comes up read-only.** That is `FDVDenyWriteAccess` - see [Caveats](#caveats). Enable BitLocker, or clear the setting if it is yours to clear - **The `.vhdx` is gone after a restart.** Windows refused `ATTACH_VIRTUAL_DISK_FLAG_AT_BOOT`, or automatic mounting was declined. Mount it by hand with the command the script printed - **A run failed part-way.** It names what it left behind - a `.vhdx` still attached, or a volume already shrunk. Undo that before running again: a rerun starts from the beginning and would shrink the drive a second time diff --git a/dev_drive.Tests.ps1 b/dev_drive.Tests.ps1 index e1e5a3d..63b5436 100644 --- a/dev_drive.Tests.ps1 +++ b/dev_drive.Tests.ps1 @@ -132,6 +132,41 @@ Describe 'The script itself' { '-TaskNames \$ownTaskNames -VolumeTaskName \$devTaskName') } + # Measured on a scratch disk laid out as A(15) | gap 15 GB | C(5) | tail 25 GB: -UseMaximumSize + # put the partition in the tail and left the freed 15 GB untouched. So the shrink branch must + # place its partition itself, and only the .vhdx branch - one disk, one free run, made moments + # earlier - may still ask for the maximum. + It 'places the shrink partition itself, and asks for the maximum only inside the vhdx branch' { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $script:ScriptPath, [ref]$null, [ref]$null) + + $calls = @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'New-Partition' + }, $true)) + $calls.Count | Should -BeGreaterThan 1 + + foreach ($call in $calls) { + $parameters = @($call.CommandElements | + Where-Object { $_ -is [System.Management.Automation.Language.CommandParameterAst] } | + ForEach-Object { $_.ParameterName }) + if ($parameters -contains 'UseMaximumSize') { + # The one allowed use names the virtual disk's own number, nothing else. + $call.Extent.Text | Should -Match '\$vhdxDiskNumber' -Because 'only the vhdx branch may take the maximum' + } + else { + $parameters | Should -Contain 'Size' -Because 'every other partition is given an explicit size' + } + } + + $shrinkCall = @($calls | Where-Object { $_.Extent.Text -match '\$freedOffset' }) + $shrinkCall.Count | Should -Be 1 + @($shrinkCall[0].CommandElements | + Where-Object { $_ -is [System.Management.Automation.Language.CommandParameterAst] } | + ForEach-Object { $_.ParameterName }) | Should -Contain 'Offset' + } + # A text search would pass the moment the loop were spelled differently, so ask the syntax tree. # Both shapes count: a loop statement, and the script block ForEach-Object would be handed. It 'writes the daily schedule with a single call, from no loop of any shape' { @@ -638,48 +673,149 @@ Describe 'The script itself' { $reportAt | Should -BeGreaterThan $finalStateAt } - It 'checks the target against the size Windows itself reports as the minimum, before it resizes' { + It 'decides the resize with the sizes Windows reported for that very partition' { + # Without this the function could be tested to perfection and still be handed the wrong + # arguments - a literal minimum, or the current size passed as the maximum. $content = Get-Content -Path $script:ScriptPath -Raw + $content | Should -Match ('(?ms)\$shrinkPlan = Resolve-ShrinkPlan -CurrentSize \$partitionInfo\.Size -MaxSize \$maxSize `\s*\r?\n\s*' + + '-MinSize \$minSize -ShrinkBytes \(ConvertTo-ByteCount -GB \$ShrinkGB\)') $minBoundAt = $content.IndexOf('$minSize = $supportedSizes.SizeMin') - $guardAt = $content.IndexOf('if ($targetSize -lt $minSize)') + $guardAt = $content.IndexOf('if ($shrinkPlan.Rejection) {', $minBoundAt) $resizeAt = $content.IndexOf('Resize-Partition -DiskNumber $diskNum') $minBoundAt | Should -BeGreaterThan 0 $guardAt | Should -BeGreaterThan $minBoundAt $resizeAt | Should -BeGreaterThan $guardAt } - It 'ends the run on a plain refusal, not a throw, when the target is below what Windows allows' { + It 'ends the run on a plain refusal, not a throw, when the numbers do not allow the shrink' { # The guard fires before Resize-Partition ever runs, so this must not fall into the catch # that would otherwise warn a shrink already happened. $content = Get-Content -Path $script:ScriptPath -Raw - $guardAt = $content.IndexOf('if ($targetSize -lt $minSize)') + $minBoundAt = $content.IndexOf('$minSize = $supportedSizes.SizeMin') + $guardAt = $content.IndexOf('if ($shrinkPlan.Rejection) {', $minBoundAt) $blockEnd = $content.IndexOf('Write-Host "Resizing Partition') + $guardAt | Should -BeGreaterThan 0 $guardBlock = $content.Substring($guardAt, $blockEnd - $guardAt) $guardBlock | Should -Not -Match 'throw' $guardBlock | Should -Match 'exit 1' } - It 'names both the target and the Windows minimum in that refusal with the rounding helpers' { + It 'hands that refusal the Windows minimum, rounded the safe way, and never a rejection name' { + # Ceilinged, not floored: a floored minimum would name a size Windows still refuses. $content = Get-Content -Path $script:ScriptPath -Raw - $guardAt = $content.IndexOf('if ($targetSize -lt $minSize)') + $minBoundAt = $content.IndexOf('$minSize = $supportedSizes.SizeMin') + $guardAt = $content.IndexOf('if ($shrinkPlan.Rejection) {', $minBoundAt) $blockEnd = $content.IndexOf('Write-Host "Resizing Partition') $guardBlock = $content.Substring($guardAt, $blockEnd - $guardAt) - $guardBlock | Should -Match 'ConvertTo-FlooredGB -Bytes \$targetSize' + $guardBlock | Should -Match 'Format-ShrinkRefusal -DriveLetter \$DriveLetter -ShrinkGB \$ShrinkGB -Rejection \$shrinkPlan\.Rejection' $guardBlock | Should -Match 'ConvertTo-CeilingedGB -Bytes \$minSize' + $guardBlock | Should -Not -Match '\$\(\$shrinkPlan\.Rejection\)' } It 'shows the resize target and its sizes with the rounding helpers, not a bare Math.Round' { # Scoped to the try block's resize section, not the earlier drive-selection prompt, which - # has its own bare Round calls outside this change's reach. + # has its own bare Round calls outside this change's reach. Ends at the creation call so the + # size printed just above it stays inside the region this guards. $content = Get-Content -Path $script:ScriptPath -Raw $blockStart = $content.IndexOf('# Use stored partition information to avoid redundant API calls') - $blockEnd = $content.IndexOf('# Create Dev Drive from the freed space') + $blockEnd = $content.IndexOf('$newPart = New-Partition -DiskNumber $diskNum -Offset') $blockStart | Should -BeGreaterThan 0 $blockEnd | Should -BeGreaterThan $blockStart $block = $content.Substring($blockStart, $blockEnd - $blockStart) $block | Should -Not -Match '\[math\]::Round\([^)]*/ 1GB, 2\)' } + It 'derives where and how big the new partition is from the read-back, not from the prediction' { + # Alignment can leave the partition a little off the target, and the offset written to disk + # has to be where the partition actually ends. + $content = Get-Content -Path $script:ScriptPath -Raw + $content | Should -Match '\$shrunkPart = Get-Partition -DiskNumber \$diskNum -PartitionNumber \$partitionInfo\.PartitionNumber' + $content | Should -Match ('(?ms)Resolve-AlignedPlacement -Offset \(\$shrunkPart\.Offset \+ \$shrunkPart\.Size\) `\s*\r?\n\s*' + + '-Size \(\$maxSize - \$shrunkPart\.Size\)') + $content | Should -Not -Match 'Resolve-AlignedPlacement -Offset .*\$shrinkPlan\.' + } + + It 'aligns the start before New-Partition, because an offset off a megabyte is refused outright' { + # Measured: five non-round shrink amounts produced five unaligned offsets, and every one was + # refused - after the volume had already been shrunk. + $content = Get-Content -Path $script:ScriptPath -Raw + $guardAt = $content.IndexOf('if ($shrunkPart.Size -gt $maxSize) {') + $alignAt = $content.IndexOf('$placement = Resolve-AlignedPlacement -Offset') + $offsetAt = $content.IndexOf('$freedOffset = $placement.Offset') + $createAt = $content.IndexOf('$newPart = New-Partition -DiskNumber $diskNum -Offset') + $guardAt | Should -BeGreaterThan 0 + $alignAt | Should -BeGreaterThan $guardAt + $offsetAt | Should -BeGreaterThan $alignAt + $createAt | Should -BeGreaterThan $offsetAt + } + + It 'names the adjoining space after the refusal and before the question, never before both' { + # Before the refusal it would promise a drive the very next line turns down; after the + # question it would come too late to inform the number. + $content = Get-Content -Path $script:ScriptPath -Raw + $refuseAt = $content.IndexOf('below the $DevDriveMinSizeGB GB minimum required for a Dev Drive') + $measureAt = $content.IndexOf('$ShrinkAdjoiningGB = ConvertTo-FlooredGB -Bytes ($supportedSizes.SizeMax - $partitionInfo.Size)') + $noteAt = $content.IndexOf('Format-ShrinkAdjoiningNote -DriveLetter $DriveLetter') + $askAt = $content.IndexOf("Request-DevDriveSizeGB -MaxGB `$realMaxShrinkableGB -Subject 'Shrink amount'") + $refuseAt | Should -BeGreaterThan 0 + $measureAt | Should -BeGreaterThan $refuseAt + $noteAt | Should -BeGreaterThan $measureAt + $askAt | Should -BeGreaterThan $noteAt + } + + # The headline behaviour: without this the plan could quietly go back to naming the amount + # freed, every existing test would stay green, and the note would read "comes out 200 GB + # rather than the 200 GB being freed". + It 'plans the drive at the size the shrink decision gives, not at the amount freed' { + $content = Get-Content -Path $script:ScriptPath -Raw + $content | Should -Match ('(?ms)\$shrinkPlan = Resolve-ShrinkPlan -CurrentSize \$partitionInfo\.Size ' + + '-MaxSize \$supportedSizes\.SizeMax `\s*\r?\n\s*-MinSize \$supportedSizes\.SizeMin ' + + '-ShrinkBytes \(ConvertTo-ByteCount -GB \$ShrinkGB\)') + $content | Should -Match '\$SizeGB = ConvertTo-FlooredGB -Bytes \$shrinkPlan\.DevDriveBytes' + } + + It 'refuses a drive that came out below the minimum, rather than only reporting its size' { + $content = Get-Content -Path $script:ScriptPath -Raw + $floorAt = $content.IndexOf('if ($freedSize -lt ($DevDriveMinSizeGB * 1GB)) {') + $createAt = $content.IndexOf('$newPart = New-Partition -DiskNumber $diskNum -Offset') + $floorAt | Should -BeGreaterThan 0 + $createAt | Should -BeGreaterThan $floorAt + } + + It 'says so when the space behind the drive did not come to what the plan named' { + $content = Get-Content -Path $script:ScriptPath -Raw + # Compared at the precision the two are shown in, so the megabyte the alignment takes back + # cannot make every single run announce a discrepancy nobody can see. + $content | Should -Match '\(ConvertTo-FlooredGB -Bytes \$freedSize\) -ne \(ConvertTo-FlooredGB -Bytes \$shrinkPlan\.DevDriveBytes\)' + $content | Should -Match 'not the \$\(ConvertTo-FlooredGB -Bytes \$shrinkPlan\.DevDriveBytes\) GB the plan named' + } + + It 'warns about the extra space in the plan, before the question that asks to proceed' { + # The whole point of the change: the user is told the drive comes out larger, and told it + # while there is still something to say no to. + $content = Get-Content -Path $script:ScriptPath -Raw + $noteAt = $content.IndexOf('Format-ShrinkSizeNote -DriveLetter $DriveLetter -ShrinkGB $ShrinkGB') + $planAt = $content.IndexOf('DEV DRIVE CREATION PLAN') + $confirmAt = $content.IndexOf('Are you ready to proceed') + $planAt | Should -BeGreaterThan 0 + $noteAt | Should -BeGreaterThan $planAt + $confirmAt | Should -BeGreaterThan $noteAt + } + + It 'says the size is approximate where the adjoining space could not be measured' { + # That branch knows the drive will differ and cannot say by how much, so it must not print + # the number as though it were the answer. Not "at least", either: alignment can leave the + # partition a little off the target, so the figure is not a floor. + $content = Get-Content -Path $script:ScriptPath -Raw + $branchAt = $content.IndexOf('if ($null -eq $ShrinkAdjoiningGB) {') + $confirmAt = $content.IndexOf('Are you ready to proceed') + $branchAt | Should -BeGreaterThan 0 + $confirmAt | Should -BeGreaterThan $branchAt + $branch = $content.Substring($branchAt, $confirmAt - $branchAt) + $branch | Should -Match 'About this much, and likely more' + $branch | Should -Match 'could not be measured beforehand' + } + It 'records the shrunk drive letter right after the resize, before anything else can throw' { $content = Get-Content -Path $script:ScriptPath -Raw $resizeAt = $content.IndexOf('Resize-Partition -DiskNumber $diskNum -PartitionNumber $partitionInfo.PartitionNumber') @@ -876,6 +1012,218 @@ Describe 'Resolve-VhdxPathInput' { } } +Describe 'Resolve-ShrinkPlan' { + # SizeMax is the partition's size plus the unallocated space right behind it - measured on a + # scratch disk, where a 20 GB partition with a 10 GB gap behind it reported SizeMax 30 GB. + It 'takes the amount asked for off the partition, and hands the drive the whole free run behind it' { + # Drive D is 1000 GB with 50 GB unallocated next to it; the user frees 200 GB. + $plan = Resolve-ShrinkPlan -CurrentSize 1000GB -MaxSize 1050GB -MinSize 300GB -ShrinkBytes 200GB + $plan.Rejection | Should -BeNullOrEmpty + $plan.TargetBytes | Should -Be 800GB -Because 'the partition gives up exactly what was asked for' + $plan.DevDriveBytes | Should -Be 250GB -Because 'the 200 GB freed plus the 50 GB already there' + $plan.AdjoiningBytes | Should -Be 50GB + } + + # Measured end to end on a USB disk laid out as shrinkme(40) | gap 4 | part(5) | tail 70.23: + # freeing 6 GB left the volume at 34 GB and produced a 10 GB partition right behind it. + It 'matches what a real disk did, including that the far larger tail was not what it measured' { + $plan = Resolve-ShrinkPlan -CurrentSize 40GB -MaxSize 44GB -MinSize 3.05GB -ShrinkBytes 6GB + $plan.TargetBytes | Should -Be 34GB + $plan.DevDriveBytes | Should -Be 10GB + $plan.AdjoiningBytes | Should -Be 4GB + } + + It 'hands the drive exactly the amount freed when nothing adjoins the partition' { + $plan = Resolve-ShrinkPlan -CurrentSize 1000GB -MaxSize 1000GB -MinSize 300GB -ShrinkBytes 200GB + $plan.TargetBytes | Should -Be 800GB + $plan.DevDriveBytes | Should -Be 200GB + $plan.AdjoiningBytes | Should -Be 0 + } + + # The defect this replaced: the target was SizeMax minus the shrink amount, so a partition with + # more space behind it than the user asked to free was made LARGER by a request to shrink it. + It 'never grows the partition, however much unallocated space adjoins it' { + $plan = Resolve-ShrinkPlan -CurrentSize 1000GB -MaxSize 1050GB -MinSize 300GB -ShrinkBytes 20GB + $plan.TargetBytes | Should -Be 980GB + $plan.TargetBytes | Should -BeLessThan 1000GB + $plan.DevDriveBytes | Should -Be 70GB + } + + It 'refuses ' -TestCases @( + @{ Case = 'a target below what Windows will allow'; Current = 1000GB; Max = 1000GB; Min = 900GB; Shrink = 200GB; Rejection = 'TargetBelowMinimum' } + @{ Case = 'shrinking by the whole partition'; Current = 1000GB; Max = 1000GB; Min = 0; Shrink = 1000GB; Rejection = 'ShrinkExceedsPartition' } + @{ Case = 'shrinking by more than there is'; Current = 1000GB; Max = 1000GB; Min = 0; Shrink = 1200GB; Rejection = 'ShrinkExceedsPartition' } + @{ Case = 'a maximum below the current size'; Current = 1000GB; Max = 900GB; Min = 0; Shrink = 100GB; Rejection = 'MaxBelowCurrent' } + ) { + $plan = Resolve-ShrinkPlan -CurrentSize $Current -MaxSize $Max -MinSize $Min -ShrinkBytes $Shrink + $plan.Rejection | Should -Be $Rejection + } + + # Its own vocabulary: Resolve-DevDriveSizeInput answers 'BelowMinimum' about a typed size, and a + # shared literal would let a mis-wired comparison read one function's verdict as the other's. + It 'names its rejections apart from the ones the size question uses' { + $plan = Resolve-ShrinkPlan -CurrentSize 1000GB -MaxSize 1000GB -MinSize 900GB -ShrinkBytes 200GB + $plan.Rejection | Should -Not -Be 'BelowMinimum' + } + + It 'answers zero sizes when it refuses , so a caller cannot act on a number it never got' -TestCases @( + @{ Case = 'a target below what Windows will allow'; Current = 1000GB; Max = 1000GB; Min = 900GB; Shrink = 200GB } + @{ Case = 'shrinking by the whole partition'; Current = 1000GB; Max = 1000GB; Min = 0; Shrink = 1000GB } + @{ Case = 'a maximum below the current size'; Current = 1000GB; Max = 900GB; Min = 0; Shrink = 100GB } + ) { + $plan = Resolve-ShrinkPlan -CurrentSize $Current -MaxSize $Max -MinSize $Min -ShrinkBytes $Shrink + $plan.Rejection | Should -Not -BeNullOrEmpty + $plan.TargetBytes | Should -Be 0 + $plan.DevDriveBytes | Should -Be 0 + $plan.AdjoiningBytes | Should -Be 0 + } +} + +Describe 'Format-ShrinkAdjoiningNote' { + It 'names the drive, the amount, and that it joins the new Dev Drive' { + $note = @(Format-ShrinkAdjoiningNote -DriveLetter 'D' -AdjoiningGB 4) + $note | Should -HaveCount 1 + $note[0] | Should -Match 'Unallocated right behind D: 4 GB' + $note[0] | Should -Match 'it joins the new Dev Drive' + } + + It 'stays silent for GB, so no run invents a number' -TestCases @( + @{ Adjoining = 0 } + @{ Adjoining = -1 } + ) { + @(Format-ShrinkAdjoiningNote -DriveLetter 'D' -AdjoiningGB $Adjoining) | Should -HaveCount 0 + } +} + +Describe 'Resolve-AlignedPlacement' { + # Measured: New-Partition refused all five offsets a non-round shrink produced - remainders of + # 125952, 12800, 545280, 923136 and 82432 bytes against 1 MB - with "The specified offset is not + # valid". A resize lands 20 to 389 bytes off its target, so this is the ordinary case, not a rare one. + It 'moves an offset with a remainder of forward to the next megabyte' -TestCases @( + @{ Remainder = 125952 } + @{ Remainder = 12800 } + @{ Remainder = 545280 } + @{ Remainder = 923136 } + @{ Remainder = 82432 } + @{ Remainder = 1 } + @{ Remainder = 1048575 } + ) { + $offset = [uint64](64MB + $Remainder) + $placement = Resolve-AlignedPlacement -Offset $offset -Size 100GB + $placement.Rejection | Should -BeNullOrEmpty + $placement.Offset % 1MB | Should -Be 0 + $placement.Offset | Should -BeGreaterThan $offset + $placement.ShiftedBy | Should -Be (1MB - $Remainder) + } + + It 'leaves an offset that is already a whole number of megabytes exactly where it is' { + $placement = Resolve-AlignedPlacement -Offset 36523999232 -Size 100GB + $placement.Offset | Should -Be 36523999232 + $placement.Size | Should -Be 100GB + $placement.ShiftedBy | Should -Be 0 + } + + It 'gives back from the size exactly what the nudge took from the front' { + $placement = Resolve-AlignedPlacement -Offset ([uint64](64MB + 700000)) -Size 100GB + $placement.Offset + $placement.Size | Should -Be (64MB + 700000 + 100GB) -Because 'the run must end where it ended' + } + + It 'refuses rather than answer a size of zero or less when the run is shorter than the nudge' -TestCases @( + @{ Size = 1 } + @{ Size = 300000 } + @{ Size = 348576 } + ) { + $placement = Resolve-AlignedPlacement -Offset ([uint64](64MB + 700000)) -Size $Size + $placement.Rejection | Should -Be 'NothingLeftAfterAligning' + $placement.Size | Should -Be 0 + } + + It 'stays exact on offsets too large for a double to hold to the byte' { + # 8 TB and change: dividing this by 1 MB as a double and multiplying back loses bytes. + $offset = [uint64]8796093022209 + $placement = Resolve-AlignedPlacement -Offset $offset -Size 100GB + $placement.Offset % 1MB | Should -Be 0 + $placement.Offset | Should -Be ([uint64]8796094070784) + } +} + +Describe 'Format-ShrinkRefusal' { + It 'says why in plain words for , naming no rejection token' -TestCases @( + @{ Rejection = 'TargetBelowMinimum'; Expect = 'will not take it below 900 GB' } + @{ Rejection = 'ShrinkExceedsPartition'; Expect = 'the whole volume is only 1000 GB' } + @{ Rejection = 'MaxBelowCurrent'; Expect = 'maximum size below its current size' } + ) { + $line = (Format-ShrinkRefusal -DriveLetter 'D' -ShrinkGB 200 -Rejection $Rejection ` + -MinSizeGB 900 -CurrentSizeGB 1000) -join ' ' + $line | Should -Match ([regex]::Escape($Expect)) + $line | Should -Not -Match $Rejection + } + + It 'refuses a rejection name it has no wording for' { + { Format-ShrinkRefusal -DriveLetter 'D' -ShrinkGB 200 -Rejection 'Whatever' ` + -MinSizeGB 900 -CurrentSizeGB 1000 } | Should -Throw + } + + # The case that will actually happen: a rejection added to Resolve-ShrinkPlan and to the + # ValidateSet, but never given wording. A default arm carrying one message would hide it. + It 'has wording for every rejection Resolve-ShrinkPlan can answer' { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $script:ScriptPath, [ref]$null, [ref]$null) + $plan = $ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Resolve-ShrinkPlan' }, $false)[0] + $answered = @([regex]::Matches($plan.Extent.Text, "Rejection = '([A-Za-z]+)'") | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) + $answered.Count | Should -BeGreaterThan 0 + + foreach ($rejection in $answered) { + { Format-ShrinkRefusal -DriveLetter 'D' -ShrinkGB 200 -Rejection $rejection ` + -MinSizeGB 900 -CurrentSizeGB 1000 } | Should -Not -Throw -Because "$rejection needs wording" + } + } +} + +Describe 'ConvertTo-ByteCount' { + It 'reads GB as bytes' -TestCases @( + @{ GB = 0; Bytes = 0 } + @{ GB = 1; Bytes = 1073741824 } + @{ GB = 2.5; Bytes = 2684354560 } + @{ GB = 0.01; Bytes = 10737418 } + ) { + ConvertTo-ByteCount -GB $GB | Should -Be $Bytes + } + + It 'answers an unsigned whole number, which is what the storage cmdlets take' { + ConvertTo-ByteCount -GB 1.5 | Should -BeOfType [uint64] + } +} + +Describe 'Format-ShrinkSizeNote' { + It 'says the drive comes out larger, by how much, and why' { + $note = (Format-ShrinkSizeNote -DriveLetter 'D' -ShrinkGB 200 -DevDriveGB 250) -join ' ' + $note | Should -Match '50 GB of unallocated space already sits next to drive D:' + $note | Should -Match 'comes out 250 GB rather than the 200 GB being freed' + } + + # Three figures on one screen: a reader adds the first two and expects the third. + It 'shows an extra of GB that is exactly the difference between the two sizes' -TestCases @( + @{ Shrink = 200; DevDrive = 250; Extra = 50 } + @{ Shrink = 50.999; DevDrive = 55.005; Extra = 4.006 } + @{ Shrink = 0.01; DevDrive = 0.02; Extra = 0.01 } + ) { + $note = (Format-ShrinkSizeNote -DriveLetter 'D' -ShrinkGB $Shrink -DevDriveGB $DevDrive) -join ' ' + $note | Should -Match ([regex]::Escape("$Extra GB of unallocated space")) + $note | Should -Match ([regex]::Escape("comes out $DevDrive GB rather than the $Shrink GB")) + } + + It 'stays silent when the drive is no larger than the amount freed' -TestCases @( + @{ Shrink = 200; DevDrive = 200 } + @{ Shrink = 200; DevDrive = 199 } + ) { + @(Format-ShrinkSizeNote -DriveLetter 'D' -ShrinkGB $Shrink -DevDriveGB $DevDrive) | Should -HaveCount 0 + } +} + Describe 'Resolve-DevDriveSizeInput' { It 'rejects as ' -TestCases @( @{ Answer = 'abc'; Rejection = 'NotANumber' } diff --git a/dev_drive.ps1 b/dev_drive.ps1 index 9c5556a..049b847 100644 --- a/dev_drive.ps1 +++ b/dev_drive.ps1 @@ -177,6 +177,12 @@ function ConvertTo-CeilingedGB { return [math]::Ceiling($Bytes / 1GB * 100) / 100 } +function ConvertTo-ByteCount { + # The other direction: a GB figure the user typed, as whole bytes for the storage cmdlets. + param([Parameter(Mandatory)][decimal]$GB) + return [uint64][math]::Round($GB * 1GB) +} + function Get-VhdxAlignedSize { # A .vhdx virtual size must be a whole number of sectors, so a fractional GB has to be trimmed. param([Parameter(Mandatory)][uint64]$SizeBytes) @@ -1477,6 +1483,116 @@ function Request-DedupSchedule { return [PSCustomObject]@{ DailyTime = $chosenTime; WeeklyDay = $chosenDay; WeeklyStart = $chosenStart } } +function Resolve-ShrinkPlan { + <# + What shrinking a partition by the requested amount produces. SizeMax is the partition's size + plus the unallocated run right behind it, so the space left free by the resize is SizeMax + minus the new size, not the amount asked for. Rejection is $null when the numbers work. + #> + param( + [Parameter(Mandatory)][uint64]$CurrentSize, + [Parameter(Mandatory)][uint64]$MaxSize, + [Parameter(Mandatory)][uint64]$MinSize, + [Parameter(Mandatory)][uint64]$ShrinkBytes + ) + + $result = [PSCustomObject]@{ + Rejection = $null; TargetBytes = [uint64]0; DevDriveBytes = [uint64]0; AdjoiningBytes = [uint64]0 + } + + # Guards before the subtraction: a difference that goes negative is not a size. + if ($MaxSize -lt $CurrentSize) { $result.Rejection = 'MaxBelowCurrent'; return $result } + if ($ShrinkBytes -ge $CurrentSize) { $result.Rejection = 'ShrinkExceedsPartition'; return $result } + + $target = $CurrentSize - $ShrinkBytes + if ($target -lt $MinSize) { $result.Rejection = 'TargetBelowMinimum'; return $result } + + $result.TargetBytes = $target + $result.DevDriveBytes = $MaxSize - $target + $result.AdjoiningBytes = $MaxSize - $CurrentSize + return $result +} + +function Resolve-AlignedPlacement { + <# + Where a partition can actually start in the run just freed. New-Partition refuses an offset + that is not a whole number of megabytes, and a resize lands wherever the cluster size leaves + it, so the start is nudged forward and the size gives back exactly what the nudge took. + #> + param( + [Parameter(Mandatory)][uint64]$Offset, + [Parameter(Mandatory)][uint64]$Size + ) + + $result = [PSCustomObject]@{ Rejection = $null; Offset = [uint64]0; Size = [uint64]0; ShiftedBy = [uint64]0 } + + # Integer arithmetic throughout: a byte offset this large loses precision as a double. + $remainder = $Offset % 1MB + $shift = if ($remainder -eq 0) { [uint64]0 } else { [uint64](1MB - $remainder) } + if ($shift -ge $Size) { $result.Rejection = 'NothingLeftAfterAligning'; return $result } + + $result.Offset = $Offset + $shift + $result.Size = $Size - $shift + $result.ShiftedBy = $shift + return $result +} + +function Format-ShrinkRefusal { + <# Why a shrink cannot go ahead, in the user's terms. One wording for both call sites, so + neither drifts nor prints a rejection name. #> + param( + [Parameter(Mandatory)][string]$DriveLetter, + [Parameter(Mandatory)][decimal]$ShrinkGB, + [Parameter(Mandatory)][ValidateSet('MaxBelowCurrent', 'ShrinkExceedsPartition', 'TargetBelowMinimum')][string]$Rejection, + [Parameter(Mandatory)][decimal]$MinSizeGB, + [Parameter(Mandatory)][decimal]$CurrentSizeGB + ) + + switch ($Rejection) { + 'TargetBelowMinimum' { + return @("Cannot shrink drive ${DriveLetter}: by $ShrinkGB GB; Windows will not take it below $MinSizeGB GB.") + } + 'ShrinkExceedsPartition' { + return @("Cannot shrink drive ${DriveLetter}: by $ShrinkGB GB; the whole volume is only $CurrentSizeGB GB.") + } + 'MaxBelowCurrent' { + return @("Cannot plan a shrink for drive ${DriveLetter}: Windows reports a maximum size below its current size.") + } + # A rejection added to Resolve-ShrinkPlan must be given wording here, not inherit somebody else's. + default { throw "Format-ShrinkRefusal has no wording for $Rejection" } + } +} + +function Format-ShrinkAdjoiningNote { + <# The one line shown beside the shrink limits: what already sits behind the drive and joins it. #> + param( + [Parameter(Mandatory)][string]$DriveLetter, + [Parameter(Mandatory)][decimal]$AdjoiningGB + ) + + if ($AdjoiningGB -le 0) { return @() } + return @(" Unallocated right behind ${DriveLetter}: $AdjoiningGB GB - it joins the new Dev Drive") +} + +function Format-ShrinkSizeNote { + <# Says the drive will come out bigger than the amount freed, and why. Nothing to say when no + unallocated space adjoins the partition. #> + param( + [Parameter(Mandatory)][string]$DriveLetter, + [Parameter(Mandatory)][decimal]$ShrinkGB, + [Parameter(Mandatory)][decimal]$DevDriveGB + ) + + # Taken as the difference between the two figures shown, so the three always add up on screen. + $extraGB = $DevDriveGB - $ShrinkGB + if ($extraGB -le 0) { return @() } + + return @( + "$extraGB GB of unallocated space already sits next to drive ${DriveLetter}: and will be taken" + "as well, so the Dev Drive comes out $DevDriveGB GB rather than the $ShrinkGB GB being freed." + ) +} + function Resolve-DevDriveSizeInput { <# Decides what one typed answer to the size question means. Kept free of Read-Host and of @@ -2381,8 +2497,34 @@ if ($mode -eq "FreeSpace") { exit 1 } + # After the refusal above, never before it: naming space that joins a drive the next line + # refuses would promise something and take it back. + $ShrinkAdjoiningGB = $null + if ($partitionInfo) { + $ShrinkAdjoiningGB = ConvertTo-FlooredGB -Bytes ($supportedSizes.SizeMax - $partitionInfo.Size) + foreach ($line in (Format-ShrinkAdjoiningNote -DriveLetter $DriveLetter -AdjoiningGB $ShrinkAdjoiningGB)) { + Write-Host $line -ForegroundColor Cyan + } + } + $ShrinkGB = Request-DevDriveSizeGB -MaxGB $realMaxShrinkableGB -Subject 'Shrink amount' - $SizeGB = $ShrinkGB # The Dev Drive fills exactly the space that was freed + + # The drive takes the whole free run behind the partition: the amount freed plus what adjoins it. + $SizeGB = $ShrinkGB + if ($partitionInfo) { + $shrinkPlan = Resolve-ShrinkPlan -CurrentSize $partitionInfo.Size -MaxSize $supportedSizes.SizeMax ` + -MinSize $supportedSizes.SizeMin -ShrinkBytes (ConvertTo-ByteCount -GB $ShrinkGB) + if ($shrinkPlan.Rejection) { + foreach ($line in (Format-ShrinkRefusal -DriveLetter $DriveLetter -ShrinkGB $ShrinkGB -Rejection $shrinkPlan.Rejection ` + -MinSizeGB (ConvertTo-CeilingedGB -Bytes $supportedSizes.SizeMin) ` + -CurrentSizeGB (ConvertTo-FlooredGB -Bytes $partitionInfo.Size))) { + Write-Host $line -ForegroundColor Red + } + Write-Host "Exiting. Nothing has been changed." -ForegroundColor Yellow + exit 1 + } + $SizeGB = ConvertTo-FlooredGB -Bytes $shrinkPlan.DevDriveBytes + } } else { # Vhdx # Compile the interop now rather than after every question, so a machine that forbids Add-Type # fails before the user has answered anything. @@ -2493,6 +2635,19 @@ if ($mode -eq "Vhdx") { } } else { Write-Host "* Create $SizeGB GB Dev Drive on Disk $DiskNumber ($selectedDiskName) using ReFS" -ForegroundColor White + if ($mode -eq "ShrinkDrive") { + if ($null -eq $ShrinkAdjoiningGB) { + # Windows would not say how much sits behind the drive, so the size above is a floor. + Write-Host " About this much, and likely more: the drive also takes any unallocated space already" -ForegroundColor Yellow + Write-Host " behind ${DriveLetter}:, which could not be measured beforehand. Its real size is reported once it exists." -ForegroundColor Yellow + } + else { + foreach ($note in (Format-ShrinkSizeNote -DriveLetter $DriveLetter -ShrinkGB $ShrinkGB ` + -DevDriveGB $SizeGB)) { + Write-Host " $note" -ForegroundColor Yellow + } + } + } } Write-Host "* Name the Dev Drive $DevDriveLabel" -ForegroundColor White @@ -2555,7 +2710,7 @@ try { Write-Host "Disk $DiskNumber largest unbroken block of free space: $freeSpaceGB GB" -ForegroundColor Green # Check if requested size is available - $requestedSizeBytes = [math]::Round($SizeGB * 1GB, 2) + $requestedSizeBytes = ConvertTo-ByteCount -GB $SizeGB if ($freeSpace -lt $requestedSizeBytes) { throw "Insufficient free space on disk $DiskNumber. Requested: $SizeGB GB, largest unbroken block available: $freeSpaceGB GB" } @@ -2583,29 +2738,58 @@ try { $minSize = $supportedSizes.SizeMin } - Write-Host "Maximum size for $DriveLetter`: $(ConvertTo-FlooredGB -Bytes $maxSize) GB" -ForegroundColor Green - $targetSize = $maxSize - [math]::Round($ShrinkGB * 1GB, 2) - Write-Host "Target size after shrinking: $(ConvertTo-FlooredGB -Bytes $targetSize) GB" -ForegroundColor Green - if ($targetSize -lt $minSize) { - Write-Host "Cannot shrink drive $DriveLetter to $(ConvertTo-FlooredGB -Bytes $targetSize) GB; Windows will not take it below $(ConvertTo-CeilingedGB -Bytes $minSize) GB." -ForegroundColor Red - Write-Host "Exiting. Please choose a different drive or use free space mode, then run the script again." -ForegroundColor Yellow + $shrinkPlan = Resolve-ShrinkPlan -CurrentSize $partitionInfo.Size -MaxSize $maxSize ` + -MinSize $minSize -ShrinkBytes (ConvertTo-ByteCount -GB $ShrinkGB) + if ($shrinkPlan.Rejection) { + foreach ($line in (Format-ShrinkRefusal -DriveLetter $DriveLetter -ShrinkGB $ShrinkGB -Rejection $shrinkPlan.Rejection ` + -MinSizeGB (ConvertTo-CeilingedGB -Bytes $minSize) ` + -CurrentSizeGB (ConvertTo-FlooredGB -Bytes $partitionInfo.Size))) { + Write-Host $line -ForegroundColor Red + } + Write-Host "Exiting. Nothing has been changed." -ForegroundColor Yellow exit 1 } - Write-Host "Resizing Partition $($partitionInfo.PartitionNumber) of disk $diskNum to $(ConvertTo-FlooredGB -Bytes $targetSize) GB ..." -ForegroundColor Green - Resize-Partition -DiskNumber $diskNum -PartitionNumber $partitionInfo.PartitionNumber -Size $targetSize -ErrorAction Stop + Write-Host "Resizing Partition $($partitionInfo.PartitionNumber) of disk $diskNum to $(ConvertTo-FlooredGB -Bytes $shrinkPlan.TargetBytes) GB ..." -ForegroundColor Green + Resize-Partition -DiskNumber $diskNum -PartitionNumber $partitionInfo.PartitionNumber -Size $shrinkPlan.TargetBytes -ErrorAction Stop $ShrunkDriveLetter = $DriveLetter - Write-Host "Shrunk drive $DriveLetter by $ShrinkGB GB" -ForegroundColor Green - # Create Dev Drive from the freed space - Write-Host "Creating a new partition from the freed space on disk $diskNum" -ForegroundColor Green - $newPart = New-Partition -DiskNumber $diskNum -UseMaximumSize -AssignDriveLetter -ErrorAction Stop + # Read back rather than assume: alignment can leave the partition a little off the target. + $shrunkPart = Get-Partition -DiskNumber $diskNum -PartitionNumber $partitionInfo.PartitionNumber -ErrorAction Stop + Write-Host "Shrunk drive $DriveLetter to $(ConvertTo-FlooredGB -Bytes $shrunkPart.Size) GB" -ForegroundColor Green + + # The shrink already happened, so this subtraction decides what gets written next; a negative + # difference would reach New-Partition as a binding error rather than as an explanation. + if ($shrunkPart.Size -gt $maxSize) { + throw "Drive $DriveLetter reports $(ConvertTo-FlooredGB -Bytes $shrunkPart.Size) GB after the resize, more than the $(ConvertTo-FlooredGB -Bytes $maxSize) GB Windows said it could hold. Nothing further was created." + } + + # Placed explicitly: -UseMaximumSize takes the largest free run on the disk, which can be elsewhere. + # Measured: an offset that is not a whole number of megabytes is refused outright, and a + # resize lands a few hundred bytes off its target, so the start has to be nudged forward. + $placement = Resolve-AlignedPlacement -Offset ($shrunkPart.Offset + $shrunkPart.Size) ` + -Size ($maxSize - $shrunkPart.Size) + if ($placement.Rejection) { + throw "Nothing usable was left behind ${DriveLetter}: after aligning the start of the new partition. The drive was shrunk; nothing was created." + } + $freedOffset = $placement.Offset + $freedSize = $placement.Size + + # Judged, not merely reported: every other entry point refuses a drive below this floor. + if ($freedSize -lt ($DevDriveMinSizeGB * 1GB)) { + throw "The space behind ${DriveLetter}: came to $(ConvertTo-FlooredGB -Bytes $freedSize) GB, below the $DevDriveMinSizeGB GB a Dev Drive needs. The drive was shrunk; nothing was created." + } + if ((ConvertTo-FlooredGB -Bytes $freedSize) -ne (ConvertTo-FlooredGB -Bytes $shrinkPlan.DevDriveBytes)) { + Write-Host "The space behind ${DriveLetter}: came to $(ConvertTo-FlooredGB -Bytes $freedSize) GB, not the $(ConvertTo-FlooredGB -Bytes $shrinkPlan.DevDriveBytes) GB the plan named." -ForegroundColor Yellow + } + Write-Host "Creating a $(ConvertTo-FlooredGB -Bytes $freedSize) GB partition in the space behind ${DriveLetter}: on disk $diskNum" -ForegroundColor Green + $newPart = New-Partition -DiskNumber $diskNum -Offset $freedOffset -Size $freedSize -AssignDriveLetter -ErrorAction Stop } else { # Vhdx Write-Host "Creating a $SizeGB GB $VhdxDiskType virtual hard disk at $VhdxPath" -ForegroundColor Green if ($VhdxDiskType -eq 'Fixed') { Write-Host "Allocating the whole file up front. This may take several minutes and cannot be interrupted." -ForegroundColor Yellow } - New-VirtualDiskFile -Path $VhdxPath -SizeBytes ([uint64][math]::Round($SizeGB * 1GB)) -DiskType $VhdxDiskType + New-VirtualDiskFile -Path $VhdxPath -SizeBytes (ConvertTo-ByteCount -GB $SizeGB) -DiskType $VhdxDiskType Write-Host "Attaching $VhdxPath" -ForegroundColor Green $VhdxAtBootGranted = Add-VirtualDiskAttachment -Path $VhdxPath -AtBoot:$VhdxAutoAttach