Friday, January 25, 2013

PowerShell for reducing the size of a VHD

Recently I have gotten myself into a situation where a VHD that I created was too large for a situation.  And as many of us know, there are tools to make VHDs smaller, only larger. 
In my case, this was an important VM to the testing that I was doing, I had absolutely no desire to build a new one.  I just wanted my VHD to be smaller so I could use it for the test I intended.
Now, since the VHD has been around we have been telling folks in the forums that there is no programmatic way to reduce the size of a VHD.  There have been lots of opinions about this, but it isn’t just a case or truncating a binary file.

The problem becomes difficult because you have no idea how the OS in that VHD has laid data down into that VHD.  Are there files way out at the far end, which would be corrupted if you simply chopped it off?  Does your program have the right to go looking and figure that out and expose my data?  If there is data out there, what should be done with it to make sure the OS that sues the VHD can actually find it – it is not as easy as moving that file, it could be a system file.  And there is always the disk layout that must be honored, sectors, blocks, etc are part of how that VHD is made.
So, what have we been telling folks that get into this situation – use disk imaging software to create an image and then apply that to a new VHD of the proper size.

I decided – lets script that.   This way I can answer a couple questions and pay attention to other things.

I use diskpart and DISM.  DISM is built in to Windows 8 / Server 2012.  And so is DiskPart.  Without the full Hyper-V Role there is no way to create a VHD using PowerShell.  Go figure.

* Disclaimer – I have only tried this with VMs that contain Server 2008 or newer.  Older than that will not have a BCD, but the script will make one.

Here is my entire script, hopefully you can follow my comments.

# Server 2012 / Windows 8 VHD resizer.  Using DISM.
# Prototyped on Server 2012 not running Hyper-V
# This utilizes PowerShell v3 and cmdlets from Server 2012 / Windows 8 - it will not run on any older OS.
# Copy write – Brian Ehlert

# Ask the path of the VHD and test it
Do {
    $imagePath = Read-Host "Please enter the full path to you VHD.  i.e. D:\VMs\MyTooBigVhd.vhd "
} until ((Test-Path -Path $imagePath ) -eq $true)  # Mount the VHD that will be getting resized

$orgVhd = Mount-DiskImage -ImagePath $imagePath -PassThru
$orgVhd = Get-DiskImage -ImagePath $orgVhd.ImagePath  # Get the partitions
$orgParts = Get-Partition -DiskNumber $orgVhd.Number  # Use DISM command line to capture the VHD one WIM file. Each partition with a different name.
$wimName = ($orgvhd.ImagePath.Split(".")[0] + ".wim") 
foreach ($part in $orgParts) {
    if ($part.Size -gt 524288000){ # skip the partition if it is less than 500MB, most likely there is no OS or it is the System Reserved partition.
        $capDir = $part.DriveLetter + ":\"
        $partNum = $part.PartitionNumber
        "Be patient, this could take a long time"
        & dism /capture-image /ImageFile:$wimName /CaptureDir:$capDir /Name:$partNum
# dismount the VHD that was just captured
Dismount-DiskImage -ImagePath $orgvhd.ImagePath
# Get the size of the WIM. As the new VHD must be larger.
$wimFile = Get-ItemProperty $wimName
# Ask the size of the new VHD in GB
Do {
$newSize = [uint32](Read-Host "How large would you like the new VHD (in whole GB)?  The WIM is" ([uint32]($wimFile.Length /1024 /1024 /1024))"GB of data ")
} until ($newSize -gt ([uint32]($wimFile.Length /1024 /1024 /1024)))
$newSize = [uint64]$newSize * 1024  # convert GB to MB 
foreach ($part in $orgParts)
    if ($part.Size -gt 524288000){ # skip the partition if it is less than 500MB.
        $capDir = $part.DriveLetter + ":\"
        $partNum = $part.PartitionNumber
        $newVhdPath = $orgvhd.ImagePath.Split(".")[0] + $partNum +"New." + $orgvhd.ImagePath.Split(".")[1]         
        $diskPart = @"
        create vdisk file="$newVhdPath" type=expandable maximum=$newSize
        select vdisk file="$newVhdPath"
        attach vdisk
        create partition primary
        format fs=ntfs quick
        $diskPart | diskpart
        $newVhd = Get-DiskImage -ImagePath $newvhdPath
        $newVhdDrive = (Get-Partition -DiskNumber $newVhd.Number)
        $newVhdLetter = (Get-Partition -DiskNumber $newVhd.Number).DriveLetter + ":"         
        "Be patient, this could take a long time"
        & dism /apply-image /ImageFile:$wimName /ApplyDir:$newVhdLetter /Name:$partNum
        New-PSDrive -PSProvider FileSystem -Name $newVhdDrive.DriveLetter -root ($newVhdDrive.DriveLetter + ":\") # Make the new volume known to your PowerShell session
        # if \Windows then assume a boot volume and create the BCD
        if ((Test-Path -Path ($newVhdLetter + "\Windows") -PathType Container) -eq $true)
            bcdboot $newVhdLetter\Windows /s $newVhdLetter
        Remove-PSDrive -PSProvider FileSystem -Name $newVhdDrive.DriveLetter
        Start-Sleep 10  # Settling time
        Dismount-DiskImage -ImagePath $newVhdPath


Anonymous said...

$newVhdPath must be between quotes in the $diskPart = @" section :
create vdisk file="$newVhdPath" type=expandable maximum=$newSize
select vdisk file="$newVhdPath"

Amazing work :)

BrianEh said...

Ah. Only if you have spaces in your path. ;-)
But yes, that would cover both cases. Nice catch.
(I just avoid spaces in paths these days - my bad assumption).

Nick M said...

Hello, i came across your script and due to the way that it got posted and the spacing that exists, it doesn't work and i can't figure out how to make it work properly. Would it be possible for you to also include a link to the script in a properly carriage returned text file that we'd be able to use as reference? Thank you!

BrianEh said...

All of the carriage returns are in there properly. You should just be able to do a copy and paste into the PowerShell ISE and it will alert you to any problems.

The formatting on the screen is not right. (I just edited my template to handle code blocks, lets give it a go).

BrianEh said...

You can get sneaky and convert from VHD to VHDX using ImageX / DISM. It is built-in.

Take what I did here for example.

In this case, simply alter the script to have the opposite disk format as your target. Instead of VHD, have the new disk be VHDX or vice versa.

This de-couples you from the dependency on Hyper-V.
Yes, you still need Win8 / Server 2012 since they know VHDX.

BrianEh said...

I literally changed one small line:
$newVhdPath = $orgvhd.ImagePath.Split(".")[0] + $partNum +"New." + "vhdx"

Forcing the VHDX and assuming the original disk was a VHD.
Server 2012 / Windows 8 have some built in assumptions based on the extension. You get the VHD format that the extension defines. That is why you just cannot modify the file extension.