├── - ├── .gitignore ├── Changelog.md ├── Functions ├── Private │ ├── Dismount-FslDisk.ps1 │ ├── Invoke-Parallel.ps1 │ ├── Mount-FslDisk.ps1 │ ├── Optimize-OneDisk.ps1 │ ├── Test-FslDependencies.ps1 │ └── Write-VhdOutput.ps1 └── Public │ └── Invoke-FslShrinkDisk.ps1 ├── Invoke-FslShrinkDisk.ps1 ├── LICENSE ├── README.md ├── Tests ├── Private │ ├── Dismount-FslDisk.Tests.ps1 │ ├── Mount-FslDisk.Tests.ps1 │ ├── Optimize-OneDisk.Tests.ps1 │ ├── Test-FslDependencies.Tests.ps1 │ ├── TestsReadme.md │ └── Write-VhdOutput.tests.ps1 └── Public │ └── Invoke-FslShrinkDisk.Tests.ps1 ├── azure-pipelines.yml └── build.ps1 /-: -------------------------------------------------------------------------------- 1 | "Name","DiskState","OriginalSizeGB","FinalSizeGB","SpaceSavedGB","FullName" 2 | "3.22","Success","$originalSizeGB","3.22","4.35","$Disk.FullName" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Visual Studio Code project files 2 | .vscode 3 | 4 | # Development environment 5 | start.ps1 6 | notes.txt 7 | Setup.ps1 8 | StartSOD.ps1 9 | Startmount.ps1 10 | Pester.ps1 11 | SetupTestManyThreads.ps1 12 | SetupTest.ps1 13 | langtest.ps1 14 | 15 | # Test results 16 | CodeCoverage.xml 17 | PesterTest.xml 18 | Coverage.xml 19 | testResults.xml 20 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | ## 2020 08 12 2 | - Removed unecessary partition resize from script 3 | - Fixed Diskpart wasn't running against disks with spaces in the path 4 | - Improved Shrink for legacy vhd disks making them more consistent 5 | - Added better error reporting if the disk doesn't mount properly 6 | - Improved file filtering so it doesn't pick up false positives -------------------------------------------------------------------------------- /Functions/Private/Dismount-FslDisk.ps1: -------------------------------------------------------------------------------- 1 | function Dismount-FslDisk { 2 | [CmdletBinding()] 3 | 4 | Param ( 5 | [Parameter( 6 | Position = 1, 7 | ValuefromPipelineByPropertyName = $true, 8 | ValuefromPipeline = $true, 9 | Mandatory = $true 10 | )] 11 | [String]$Path, 12 | 13 | [Parameter( 14 | ValuefromPipelineByPropertyName = $true, 15 | Mandatory = $true 16 | )] 17 | [String]$ImagePath, 18 | 19 | [Parameter( 20 | ValuefromPipelineByPropertyName = $true 21 | )] 22 | [Switch]$PassThru, 23 | 24 | [Parameter( 25 | ValuefromPipelineByPropertyName = $true 26 | )] 27 | [Int]$Timeout = 120 28 | ) 29 | 30 | BEGIN { 31 | Set-StrictMode -Version Latest 32 | #Requires -RunAsAdministrator 33 | } # Begin 34 | PROCESS { 35 | 36 | $mountRemoved = $false 37 | $directoryRemoved = $false 38 | 39 | # Reverse the tasks from Mount-FslDisk 40 | 41 | $timeStampDirectory = (Get-Date).AddSeconds(20) 42 | 43 | while ((Get-Date) -lt $timeStampDirectory -and $directoryRemoved -ne $true) { 44 | try { 45 | Remove-Item -Path $Path -Force -Recurse -ErrorAction Stop | Out-Null 46 | $directoryRemoved = $true 47 | } 48 | catch { 49 | $directoryRemoved = $false 50 | } 51 | } 52 | if (Test-Path $Path) { 53 | Write-Warning "Failed to delete temp mount directory $Path" 54 | } 55 | 56 | 57 | $timeStampDismount = (Get-Date).AddSeconds($Timeout) 58 | while ((Get-Date) -lt $timeStampDismount -and $mountRemoved -ne $true) { 59 | try { 60 | Dismount-DiskImage -ImagePath $ImagePath -ErrorAction Stop | Out-Null 61 | #double/triple check disk is dismounted due to disk manager service being a pain. 62 | 63 | try { 64 | $image = Get-DiskImage -ImagePath $ImagePath -ErrorAction Stop 65 | 66 | switch ($image.Attached) { 67 | $null { $mountRemoved = $false ; Start-Sleep 0.1; break } 68 | $true { $mountRemoved = $false ; break} 69 | $false { $mountRemoved = $true ; break } 70 | Default { $mountRemoved = $false } 71 | } 72 | } 73 | catch { 74 | $mountRemoved = $false 75 | } 76 | } 77 | catch { 78 | $mountRemoved = $false 79 | } 80 | } 81 | if ($mountRemoved -ne $true) { 82 | Write-Error "Failed to dismount disk $ImagePath" 83 | } 84 | 85 | If ($PassThru) { 86 | $output = [PSCustomObject]@{ 87 | MountRemoved = $mountRemoved 88 | DirectoryRemoved = $directoryRemoved 89 | } 90 | Write-Output $output 91 | } 92 | if ($directoryRemoved -and $mountRemoved) { 93 | Write-Verbose "Dismounted $ImagePath" 94 | } 95 | 96 | } #Process 97 | END { } #End 98 | } #function Dismount-FslDisk -------------------------------------------------------------------------------- /Functions/Private/Invoke-Parallel.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-Parallel { 2 | <# 3 | .SYNOPSIS 4 | Function to control parallel processing using runspaces 5 | 6 | .DESCRIPTION 7 | Function to control parallel processing using runspaces 8 | 9 | Note that each runspace will not have access to variables and commands loaded in your session or in other runspaces by default. 10 | This behaviour can be changed with parameters. 11 | 12 | .PARAMETER ScriptFile 13 | File to run against all input objects. Must include parameter to take in the input object, or use $args. Optionally, include parameter to take in parameter. Example: C:\script.ps1 14 | 15 | .PARAMETER ScriptBlock 16 | Scriptblock to run against all computers. 17 | 18 | You may use $Using: language in PowerShell 3 and later. 19 | 20 | The parameter block is added for you, allowing behaviour similar to foreach-object: 21 | Refer to the input object as $_. 22 | Refer to the parameter parameter as $parameter 23 | 24 | .PARAMETER InputObject 25 | Run script against these specified objects. 26 | 27 | .PARAMETER Parameter 28 | This object is passed to every script block. You can use it to pass information to the script block; for example, the path to a logging folder 29 | 30 | Reference this object as $parameter if using the scriptblock parameterset. 31 | 32 | .PARAMETER ImportVariables 33 | If specified, get user session variables and add them to the initial session state 34 | 35 | .PARAMETER ImportModules 36 | If specified, get loaded modules and pssnapins, add them to the initial session state 37 | 38 | .PARAMETER Throttle 39 | Maximum number of threads to run at a single time. 40 | 41 | .PARAMETER SleepTimer 42 | Milliseconds to sleep after checking for completed runspaces and in a few other spots. I would not recommend dropping below 200 or increasing above 500 43 | 44 | .PARAMETER RunspaceTimeout 45 | Maximum time in seconds a single thread can run. If execution of your code takes longer than this, it is disposed. Default: 0 (seconds) 46 | 47 | WARNING: Using this parameter requires that maxQueue be set to throttle (it will be by default) for accurate timing. Details here: 48 | http://gallery.technet.microsoft.com/Run-Parallel-Parallel-377fd430 49 | 50 | .PARAMETER NoCloseOnTimeout 51 | Do not dispose of timed out tasks or attempt to close the runspace if threads have timed out. This will prevent the script from hanging in certain situations where threads become non-responsive, at the expense of leaking memory within the PowerShell host. 52 | 53 | .PARAMETER MaxQueue 54 | Maximum number of powershell instances to add to runspace pool. If this is higher than $throttle, $timeout will be inaccurate 55 | 56 | If this is equal or less than throttle, there will be a performance impact 57 | 58 | The default value is $throttle times 3, if $runspaceTimeout is not specified 59 | The default value is $throttle, if $runspaceTimeout is specified 60 | 61 | .PARAMETER LogFile 62 | Path to a file where we can log results, including run time for each thread, whether it completes, completes with errors, or times out. 63 | 64 | .PARAMETER AppendLog 65 | Append to existing log 66 | 67 | .PARAMETER Quiet 68 | Disable progress bar 69 | 70 | .EXAMPLE 71 | Each example uses Test-ForPacs.ps1 which includes the following code: 72 | param($computer) 73 | 74 | if(test-connection $computer -count 1 -quiet -BufferSize 16){ 75 | $object = [pscustomobject] @{ 76 | Computer=$computer; 77 | Available=1; 78 | Kodak=$( 79 | if((test-path "\\$computer\c$\users\public\desktop\Kodak Direct View Pacs.url") -or (test-path "\\$computer\c$\documents and settings\all users\desktop\Kodak Direct View Pacs.url") ){"1"}else{"0"} 80 | ) 81 | } 82 | } 83 | else{ 84 | $object = [pscustomobject] @{ 85 | Computer=$computer; 86 | Available=0; 87 | Kodak="NA" 88 | } 89 | } 90 | 91 | $object 92 | 93 | .EXAMPLE 94 | Invoke-Parallel -scriptfile C:\public\Test-ForPacs.ps1 -inputobject $(get-content C:\pcs.txt) -runspaceTimeout 10 -throttle 10 95 | 96 | Pulls list of PCs from C:\pcs.txt, 97 | Runs Test-ForPacs against each 98 | If any query takes longer than 10 seconds, it is disposed 99 | Only run 10 threads at a time 100 | 101 | .EXAMPLE 102 | Invoke-Parallel -scriptfile C:\public\Test-ForPacs.ps1 -inputobject c-is-ts-91, c-is-ts-95 103 | 104 | Runs against c-is-ts-91, c-is-ts-95 (-computername) 105 | Runs Test-ForPacs against each 106 | 107 | .EXAMPLE 108 | $stuff = [pscustomobject] @{ 109 | ContentFile = "windows\system32\drivers\etc\hosts" 110 | Logfile = "C:\temp\log.txt" 111 | } 112 | 113 | $computers | Invoke-Parallel -parameter $stuff { 114 | $contentFile = join-path "\\$_\c$" $parameter.contentfile 115 | Get-Content $contentFile | 116 | set-content $parameter.logfile 117 | } 118 | 119 | This example uses the parameter argument. This parameter is a single object. To pass multiple items into the script block, we create a custom object (using a PowerShell v3 language) with properties we want to pass in. 120 | 121 | Inside the script block, $parameter is used to reference this parameter object. This example sets a content file, gets content from that file, and sets it to a predefined log file. 122 | 123 | .EXAMPLE 124 | $test = 5 125 | 1..2 | Invoke-Parallel -ImportVariables {$_ * $test} 126 | 127 | Add variables from the current session to the session state. Without -ImportVariables $Test would not be accessible 128 | 129 | .EXAMPLE 130 | $test = 5 131 | 1..2 | Invoke-Parallel {$_ * $Using:test} 132 | 133 | Reference a variable from the current session with the $Using: syntax. Requires PowerShell 3 or later. Note that -ImportVariables parameter is no longer necessary. 134 | 135 | .FUNCTIONALITY 136 | PowerShell Language 137 | 138 | .NOTES 139 | Credit to Boe Prox for the base runspace code and $Using implementation 140 | http://learn-powershell.net/2012/05/10/speedy-network-information-query-using-powershell/ 141 | http://gallery.technet.microsoft.com/scriptcenter/Speedy-Network-Information-5b1406fb#content 142 | https://github.com/proxb/PoshRSJob/ 143 | 144 | Credit to T Bryce Yehl for the Quiet and NoCloseOnTimeout implementations 145 | 146 | Credit to Sergei Vorobev for the many ideas and contributions that have improved functionality, reliability, and ease of use 147 | 148 | .LINK 149 | https://github.com/RamblingCookieMonster/Invoke-Parallel 150 | #> 151 | [cmdletbinding(DefaultParameterSetName = 'ScriptBlock')] 152 | Param ( 153 | [Parameter(Mandatory = $false, position = 0, ParameterSetName = 'ScriptBlock')] 154 | [System.Management.Automation.ScriptBlock]$ScriptBlock, 155 | 156 | [Parameter(Mandatory = $false, ParameterSetName = 'ScriptFile')] 157 | [ValidateScript( { Test-Path $_ -pathtype leaf })] 158 | $ScriptFile, 159 | 160 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 161 | [Alias('CN', '__Server', 'IPAddress', 'Server', 'ComputerName')] 162 | [PSObject]$InputObject, 163 | 164 | [PSObject]$Parameter, 165 | 166 | [switch]$ImportVariables, 167 | [switch]$ImportModules, 168 | [switch]$ImportFunctions, 169 | 170 | [int]$Throttle = 20, 171 | [int]$SleepTimer = 200, 172 | [int]$RunspaceTimeout = 0, 173 | [switch]$NoCloseOnTimeout = $false, 174 | [int]$MaxQueue, 175 | 176 | [validatescript( { Test-Path (Split-Path $_ -parent) })] 177 | [switch] $AppendLog = $false, 178 | [string]$LogFile, 179 | 180 | [switch] $Quiet = $false 181 | ) 182 | begin { 183 | #No max queue specified? Estimate one. 184 | #We use the script scope to resolve an odd PowerShell 2 issue where MaxQueue isn't seen later in the function 185 | if ( -not $PSBoundParameters.ContainsKey('MaxQueue') ) { 186 | if ($RunspaceTimeout -ne 0) { $script:MaxQueue = $Throttle } 187 | else { $script:MaxQueue = $Throttle * 3 } 188 | } 189 | else { 190 | $script:MaxQueue = $MaxQueue 191 | } 192 | $ProgressId = Get-Random 193 | Write-Verbose "Throttle: '$throttle' SleepTimer '$sleepTimer' runSpaceTimeout '$runspaceTimeout' maxQueue '$maxQueue' logFile '$logFile'" 194 | 195 | #If they want to import variables or modules, create a clean runspace, get loaded items, use those to exclude items 196 | if ($ImportVariables -or $ImportModules -or $ImportFunctions) { 197 | $StandardUserEnv = [powershell]::Create().addscript( { 198 | 199 | #Get modules, snapins, functions in this clean runspace 200 | $Modules = Get-Module | Select-Object -ExpandProperty Name 201 | $Snapins = Get-PSSnapin | Select-Object -ExpandProperty Name 202 | $Functions = Get-ChildItem function:\ | Select-Object -ExpandProperty Name 203 | 204 | #Get variables in this clean runspace 205 | #Called last to get vars like $? into session 206 | $Variables = Get-Variable | Select-Object -ExpandProperty Name 207 | 208 | #Return a hashtable where we can access each. 209 | @{ 210 | Variables = $Variables 211 | Modules = $Modules 212 | Snapins = $Snapins 213 | Functions = $Functions 214 | } 215 | }, $true).invoke()[0] 216 | 217 | if ($ImportVariables) { 218 | #Exclude common parameters, bound parameters, and automatic variables 219 | Function _temp { [cmdletbinding(SupportsShouldProcess = $True)] param() } 220 | $VariablesToExclude = @( (Get-Command _temp | Select-Object -ExpandProperty parameters).Keys + $PSBoundParameters.Keys + $StandardUserEnv.Variables ) 221 | Write-Verbose "Excluding variables $( ($VariablesToExclude | Sort-Object ) -join ", ")" 222 | 223 | # we don't use 'Get-Variable -Exclude', because it uses regexps. 224 | # One of the veriables that we pass is '$?'. 225 | # There could be other variables with such problems. 226 | # Scope 2 required if we move to a real module 227 | $UserVariables = @( Get-Variable | Where-Object { -not ($VariablesToExclude -contains $_.Name) } ) 228 | Write-Verbose "Found variables to import: $( ($UserVariables | Select-Object -expandproperty Name | Sort-Object ) -join ", " | Out-String).`n" 229 | } 230 | if ($ImportModules) { 231 | $UserModules = @( Get-Module | Where-Object { $StandardUserEnv.Modules -notcontains $_.Name -and (Test-Path $_.Path -ErrorAction SilentlyContinue) } | Select-Object -ExpandProperty Path ) 232 | $UserSnapins = @( Get-PSSnapin | Select-Object -ExpandProperty Name | Where-Object { $StandardUserEnv.Snapins -notcontains $_ } ) 233 | } 234 | if ($ImportFunctions) { 235 | $UserFunctions = @( Get-ChildItem function:\ | Where-Object { $StandardUserEnv.Functions -notcontains $_.Name } ) 236 | } 237 | } 238 | 239 | #region functions 240 | Function Get-RunspaceData { 241 | [cmdletbinding()] 242 | param( [switch]$Wait ) 243 | #loop through runspaces 244 | #if $wait is specified, keep looping until all complete 245 | Do { 246 | #set more to false for tracking completion 247 | $more = $false 248 | 249 | #Progress bar if we have inputobject count (bound parameter) 250 | if (-not $Quiet) { 251 | Write-Progress -Id $ProgressId -Activity "Running Query" -Status "Starting threads"` 252 | -CurrentOperation "$startedCount threads defined - $totalCount input objects - $script:completedCount input objects processed"` 253 | -PercentComplete $( Try { $script:completedCount / $totalCount * 100 } Catch { 0 } ) 254 | } 255 | 256 | #run through each runspace. 257 | Foreach ($runspace in $runspaces) { 258 | 259 | #get the duration - inaccurate 260 | $currentdate = Get-Date 261 | $runtime = $currentdate - $runspace.startTime 262 | $runMin = [math]::Round( $runtime.totalminutes , 2 ) 263 | 264 | #set up log object 265 | $log = "" | Select-Object Date, Action, Runtime, Status, Details 266 | $log.Action = "Removing:'$($runspace.object)'" 267 | $log.Date = $currentdate 268 | $log.Runtime = "$runMin minutes" 269 | 270 | #If runspace completed, end invoke, dispose, recycle, counter++ 271 | If ($runspace.Runspace.isCompleted) { 272 | 273 | $script:completedCount++ 274 | 275 | #check if there were errors 276 | if ($runspace.powershell.Streams.Error.Count -gt 0) { 277 | #set the logging info and move the file to completed 278 | $log.status = "CompletedWithErrors" 279 | Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] 280 | foreach ($ErrorRecord in $runspace.powershell.Streams.Error) { 281 | Write-Error -ErrorRecord $ErrorRecord 282 | } 283 | } 284 | else { 285 | #add logging details and cleanup 286 | $log.status = "Completed" 287 | Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] 288 | } 289 | 290 | #everything is logged, clean up the runspace 291 | $runspace.powershell.EndInvoke($runspace.Runspace) 292 | $runspace.powershell.dispose() 293 | $runspace.Runspace = $null 294 | $runspace.powershell = $null 295 | } 296 | #If runtime exceeds max, dispose the runspace 297 | ElseIf ( $runspaceTimeout -ne 0 -and $runtime.totalseconds -gt $runspaceTimeout) { 298 | $script:completedCount++ 299 | $timedOutTasks = $true 300 | 301 | #add logging details and cleanup 302 | $log.status = "TimedOut" 303 | Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] 304 | Write-Error "Runspace timed out at $($runtime.totalseconds) seconds for the object:`n$($runspace.object | out-string)" 305 | 306 | #Depending on how it hangs, we could still get stuck here as dispose calls a synchronous method on the powershell instance 307 | if (!$noCloseOnTimeout) { $runspace.powershell.dispose() } 308 | $runspace.Runspace = $null 309 | $runspace.powershell = $null 310 | $completedCount++ 311 | } 312 | 313 | #If runspace isn't null set more to true 314 | ElseIf ($runspace.Runspace -ne $null ) { 315 | $log = $null 316 | $more = $true 317 | } 318 | 319 | #log the results if a log file was indicated 320 | if ($logFile -and $log) { 321 | ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] | out-file $LogFile -append 322 | } 323 | } 324 | 325 | #Clean out unused runspace jobs 326 | $temphash = $runspaces.clone() 327 | $temphash | Where-Object { $_.runspace -eq $Null } | ForEach-Object { 328 | $Runspaces.remove($_) 329 | } 330 | 331 | #sleep for a bit if we will loop again 332 | if ($PSBoundParameters['Wait']) { Start-Sleep -milliseconds $SleepTimer } 333 | 334 | #Loop again only if -wait parameter and there are more runspaces to process 335 | } while ($more -and $PSBoundParameters['Wait']) 336 | 337 | #End of runspace function 338 | } 339 | #endregion functions 340 | 341 | #region Init 342 | 343 | if ($PSCmdlet.ParameterSetName -eq 'ScriptFile') { 344 | $ScriptBlock = [scriptblock]::Create( $(Get-Content $ScriptFile | out-string) ) 345 | } 346 | elseif ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') { 347 | #Start building parameter names for the param block 348 | [string[]]$ParamsToAdd = '$_' 349 | if ( $PSBoundParameters.ContainsKey('Parameter') ) { 350 | $ParamsToAdd += '$Parameter' 351 | } 352 | 353 | $UsingVariableData = $Null 354 | 355 | # This code enables $Using support through the AST. 356 | # This is entirely from Boe Prox, and his https://github.com/proxb/PoshRSJob module; all credit to Boe! 357 | 358 | if ($PSVersionTable.PSVersion.Major -gt 2) { 359 | #Extract using references 360 | $UsingVariables = $ScriptBlock.ast.FindAll( { $args[0] -is [System.Management.Automation.Language.UsingExpressionAst] }, $True) 361 | 362 | If ($UsingVariables) { 363 | $List = New-Object 'System.Collections.Generic.List`1[System.Management.Automation.Language.VariableExpressionAst]' 364 | ForEach ($Ast in $UsingVariables) { 365 | [void]$list.Add($Ast.SubExpression) 366 | } 367 | 368 | $UsingVar = $UsingVariables | Group-Object -Property SubExpression | ForEach-Object { $_.Group | Select-Object -First 1 } 369 | 370 | #Extract the name, value, and create replacements for each 371 | $UsingVariableData = ForEach ($Var in $UsingVar) { 372 | try { 373 | $Value = Get-Variable -Name $Var.SubExpression.VariablePath.UserPath -ErrorAction Stop 374 | [pscustomobject]@{ 375 | Name = $Var.SubExpression.Extent.Text 376 | Value = $Value.Value 377 | NewName = ('$__using_{0}' -f $Var.SubExpression.VariablePath.UserPath) 378 | NewVarName = ('__using_{0}' -f $Var.SubExpression.VariablePath.UserPath) 379 | } 380 | } 381 | catch { 382 | Write-Error "$($Var.SubExpression.Extent.Text) is not a valid Using: variable!" 383 | } 384 | } 385 | $ParamsToAdd += $UsingVariableData | Select-Object -ExpandProperty NewName -Unique 386 | 387 | $NewParams = $UsingVariableData.NewName -join ', ' 388 | $Tuple = [Tuple]::Create($list, $NewParams) 389 | $bindingFlags = [Reflection.BindingFlags]"Default,NonPublic,Instance" 390 | $GetWithInputHandlingForInvokeCommandImpl = ($ScriptBlock.ast.gettype().GetMethod('GetWithInputHandlingForInvokeCommandImpl', $bindingFlags)) 391 | 392 | $StringScriptBlock = $GetWithInputHandlingForInvokeCommandImpl.Invoke($ScriptBlock.ast, @($Tuple)) 393 | 394 | $ScriptBlock = [scriptblock]::Create($StringScriptBlock) 395 | 396 | Write-Verbose $StringScriptBlock 397 | } 398 | } 399 | 400 | $ScriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock("param($($ParamsToAdd -Join ", "))`r`n" + $Scriptblock.ToString()) 401 | } 402 | else { 403 | Throw "Must provide ScriptBlock or ScriptFile"; Break 404 | } 405 | 406 | Write-Debug "`$ScriptBlock: $($ScriptBlock | Out-String)" 407 | Write-Verbose "Creating runspace pool and session states" 408 | 409 | #If specified, add variables and modules/snapins to session state 410 | $sessionstate = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() 411 | if ($ImportVariables -and $UserVariables.count -gt 0) { 412 | foreach ($Variable in $UserVariables) { 413 | $sessionstate.Variables.Add((New-Object -TypeName System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $Variable.Name, $Variable.Value, $null) ) 414 | } 415 | } 416 | if ($ImportModules) { 417 | if ($UserModules.count -gt 0) { 418 | foreach ($ModulePath in $UserModules) { 419 | $sessionstate.ImportPSModule($ModulePath) 420 | } 421 | } 422 | if ($UserSnapins.count -gt 0) { 423 | foreach ($PSSnapin in $UserSnapins) { 424 | [void]$sessionstate.ImportPSSnapIn($PSSnapin, [ref]$null) 425 | } 426 | } 427 | } 428 | if ($ImportFunctions -and $UserFunctions.count -gt 0) { 429 | foreach ($FunctionDef in $UserFunctions) { 430 | $sessionstate.Commands.Add((New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $FunctionDef.Name, $FunctionDef.ScriptBlock)) 431 | } 432 | } 433 | 434 | #Create runspace pool 435 | $runspacepool = [runspacefactory]::CreateRunspacePool(1, $Throttle, $sessionstate, $Host) 436 | $runspacepool.Open() 437 | 438 | Write-Verbose "Creating empty collection to hold runspace jobs" 439 | $Script:runspaces = New-Object System.Collections.ArrayList 440 | 441 | #If inputObject is bound get a total count and set bound to true 442 | $bound = $PSBoundParameters.keys -contains "InputObject" 443 | if (-not $bound) { 444 | [System.Collections.ArrayList]$allObjects = @() 445 | } 446 | 447 | #Set up log file if specified 448 | if ( $LogFile -and (-not (Test-Path $LogFile) -or $AppendLog -eq $false)) { 449 | New-Item -ItemType file -Path $logFile -Force | Out-Null 450 | ("" | Select-Object -Property Date, Action, Runtime, Status, Details | ConvertTo-Csv -NoTypeInformation -Delimiter ";")[0] | Out-File $LogFile 451 | } 452 | 453 | #write initial log entry 454 | $log = "" | Select-Object -Property Date, Action, Runtime, Status, Details 455 | $log.Date = Get-Date 456 | $log.Action = "Batch processing started" 457 | $log.Runtime = $null 458 | $log.Status = "Started" 459 | $log.Details = $null 460 | if ($logFile) { 461 | ($log | convertto-csv -Delimiter ";" -NoTypeInformation)[1] | Out-File $LogFile -Append 462 | } 463 | $timedOutTasks = $false 464 | #endregion INIT 465 | } 466 | process { 467 | #add piped objects to all objects or set all objects to bound input object parameter 468 | if ($bound) { 469 | $allObjects = $InputObject 470 | } 471 | else { 472 | [void]$allObjects.add( $InputObject ) 473 | } 474 | } 475 | end { 476 | #Use Try/Finally to catch Ctrl+C and clean up. 477 | try { 478 | #counts for progress 479 | $totalCount = $allObjects.count 480 | $script:completedCount = 0 481 | $startedCount = 0 482 | foreach ($object in $allObjects) { 483 | #region add scripts to runspace pool 484 | #Create the powershell instance, set verbose if needed, supply the scriptblock and parameters 485 | $powershell = [powershell]::Create() 486 | 487 | if ($VerbosePreference -eq 'Continue') { 488 | [void]$PowerShell.AddScript( { $VerbosePreference = 'Continue' }) 489 | } 490 | 491 | [void]$PowerShell.AddScript($ScriptBlock).AddArgument($object) 492 | 493 | if ($parameter) { 494 | [void]$PowerShell.AddArgument($parameter) 495 | } 496 | 497 | # $Using support from Boe Prox 498 | if ($UsingVariableData) { 499 | Foreach ($UsingVariable in $UsingVariableData) { 500 | Write-Verbose "Adding $($UsingVariable.Name) with value: $($UsingVariable.Value)" 501 | [void]$PowerShell.AddArgument($UsingVariable.Value) 502 | } 503 | } 504 | 505 | #Add the runspace into the powershell instance 506 | $powershell.RunspacePool = $runspacepool 507 | 508 | #Create a temporary collection for each runspace 509 | $temp = "" | Select-Object PowerShell, StartTime, object, Runspace 510 | $temp.PowerShell = $powershell 511 | $temp.StartTime = Get-Date 512 | $temp.object = $object 513 | 514 | #Save the handle output when calling BeginInvoke() that will be used later to end the runspace 515 | $temp.Runspace = $powershell.BeginInvoke() 516 | $startedCount++ 517 | 518 | #Add the temp tracking info to $runspaces collection 519 | Write-Verbose ( "Adding {0} to collection at {1}" -f $temp.object, $temp.starttime.tostring() ) 520 | $runspaces.Add($temp) | Out-Null 521 | 522 | #loop through existing runspaces one time 523 | Get-RunspaceData 524 | 525 | #If we have more running than max queue (used to control timeout accuracy) 526 | #Script scope resolves odd PowerShell 2 issue 527 | $firstRun = $true 528 | while ($runspaces.count -ge $Script:MaxQueue) { 529 | #give verbose output 530 | if ($firstRun) { 531 | Write-Verbose "$($runspaces.count) items running - exceeded $Script:MaxQueue limit." 532 | } 533 | $firstRun = $false 534 | 535 | #run get-runspace data and sleep for a short while 536 | Get-RunspaceData 537 | Start-Sleep -Milliseconds $sleepTimer 538 | } 539 | #endregion add scripts to runspace pool 540 | } 541 | Write-Verbose ( "Finish processing the remaining runspace jobs: {0}" -f ( @($runspaces | Where-Object { $_.Runspace -ne $Null }).Count) ) 542 | 543 | Get-RunspaceData -wait 544 | if (-not $quiet) { 545 | Write-Progress -Id $ProgressId -Activity "Running Query" -Status "Starting threads" -Completed 546 | } 547 | } 548 | finally { 549 | #Close the runspace pool, unless we specified no close on timeout and something timed out 550 | if ( ($timedOutTasks -eq $false) -or ( ($timedOutTasks -eq $true) -and ($noCloseOnTimeout -eq $false) ) ) { 551 | Write-Verbose "Closing the runspace pool" 552 | $runspacepool.close() 553 | } 554 | #collect garbage 555 | [gc]::Collect() 556 | } 557 | } 558 | } -------------------------------------------------------------------------------- /Functions/Private/Mount-FslDisk.ps1: -------------------------------------------------------------------------------- 1 | function Mount-FslDisk { 2 | [CmdletBinding()] 3 | 4 | Param ( 5 | [Parameter( 6 | Position = 1, 7 | ValuefromPipelineByPropertyName = $true, 8 | ValuefromPipeline = $true, 9 | Mandatory = $true 10 | )] 11 | [alias('FullName')] 12 | [System.String]$Path, 13 | 14 | [Parameter( 15 | ValuefromPipelineByPropertyName = $true, 16 | ValuefromPipeline = $true 17 | )] 18 | [Int]$TimeOut = 3, 19 | 20 | [Parameter( 21 | ValuefromPipelineByPropertyName = $true 22 | )] 23 | [Switch]$PassThru 24 | ) 25 | 26 | BEGIN { 27 | Set-StrictMode -Version Latest 28 | #Requires -RunAsAdministrator 29 | } # Begin 30 | PROCESS { 31 | 32 | try { 33 | # Mount the disk without a drive letter and get it's info, Mount-DiskImage is used to remove reliance on Hyper-V tools 34 | $mountedDisk = Mount-DiskImage -ImagePath $Path -NoDriveLetter -PassThru -ErrorAction Stop 35 | } 36 | catch { 37 | $e = $error[0] 38 | Write-Error "Failed to mount disk - `"$e`"" 39 | return 40 | } 41 | 42 | 43 | $diskNumber = $false 44 | $timespan = (Get-Date).AddSeconds($TimeOut) 45 | while ($diskNumber -eq $false -and $timespan -gt (Get-Date)) { 46 | Start-Sleep 0.1 47 | try { 48 | $mountedDisk = Get-DiskImage -ImagePath $Path 49 | if ($mountedDisk.Number) { 50 | $diskNumber = $true 51 | } 52 | } 53 | catch { 54 | $diskNumber = $false 55 | } 56 | 57 | } 58 | 59 | if ($diskNumber -eq $false) { 60 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 61 | catch { 62 | Write-Error 'Could not dismount Disk Due to no Disknumber' 63 | } 64 | Write-Error 'Cannot get mount information' 65 | return 66 | } 67 | 68 | $partitionType = $false 69 | $timespan = (Get-Date).AddSeconds($TimeOut) 70 | while ($partitionType -eq $false -and $timespan -gt (Get-Date)) { 71 | 72 | try { 73 | $allPartition = Get-Partition -DiskNumber $mountedDisk.Number -ErrorAction Stop 74 | 75 | if ($allPartition.Type -contains 'Basic') { 76 | $partitionType = $true 77 | $partition = $allPartition | Where-Object -Property 'Type' -EQ -Value 'Basic' 78 | } 79 | } 80 | catch { 81 | if (($allPartition | Measure-Object).Count -gt 0) { 82 | $partition = $allPartition | Select-Object -Last 1 83 | $partitionType = $true 84 | } 85 | else{ 86 | 87 | $partitionType = $false 88 | } 89 | 90 | } 91 | Start-Sleep 0.1 92 | } 93 | 94 | if ($partitionType -eq $false) { 95 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 96 | catch { 97 | Write-Error 'Could not dismount disk with no partition' 98 | } 99 | Write-Error 'Cannot get partition information' 100 | return 101 | } 102 | 103 | # Assign vhd to a random path in temp folder so we don't have to worry about free drive letters which can be horrible 104 | # New-Guid not used here for PoSh 3 compatibility 105 | $tempGUID = [guid]::NewGuid().ToString() 106 | $mountPath = Join-Path $Env:Temp ('FSLogixMnt-' + $tempGUID) 107 | 108 | try { 109 | # Create directory which we will mount too 110 | New-Item -Path $mountPath -ItemType Directory -ErrorAction Stop | Out-Null 111 | } 112 | catch { 113 | $e = $error[0] 114 | # Cleanup 115 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 116 | catch { 117 | Write-Error "Could not dismount disk when no folder could be created - `"$e`"" 118 | } 119 | Write-Error "Failed to create mounting directory - `"$e`"" 120 | return 121 | } 122 | 123 | try { 124 | $addPartitionAccessPathParams = @{ 125 | DiskNumber = $mountedDisk.Number 126 | PartitionNumber = $partition.PartitionNumber 127 | AccessPath = $mountPath 128 | ErrorAction = 'Stop' 129 | } 130 | 131 | Add-PartitionAccessPath @addPartitionAccessPathParams 132 | } 133 | catch { 134 | $e = $error[0] 135 | # Cleanup 136 | Remove-Item -Path $mountPath -Force -Recurse -ErrorAction SilentlyContinue 137 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 138 | catch { 139 | Write-Error "Could not dismount disk when no junction point could be created - `"$e`"" 140 | } 141 | Write-Error "Failed to create junction point to - `"$e`"" 142 | return 143 | } 144 | 145 | if ($PassThru) { 146 | # Create output required for piping to Dismount-FslDisk 147 | $output = [PSCustomObject]@{ 148 | Path = $mountPath 149 | DiskNumber = $mountedDisk.Number 150 | ImagePath = $mountedDisk.ImagePath 151 | PartitionNumber = $partition.PartitionNumber 152 | } 153 | Write-Output $output 154 | } 155 | Write-Verbose "Mounted $Path to $mountPath" 156 | } #Process 157 | END { 158 | 159 | } #End 160 | } #function Mount-FslDisk -------------------------------------------------------------------------------- /Functions/Private/Optimize-OneDisk.ps1: -------------------------------------------------------------------------------- 1 | function Optimize-OneDisk { 2 | [CmdletBinding()] 3 | 4 | Param ( 5 | [Parameter( 6 | ValuefromPipelineByPropertyName = $true, 7 | ValuefromPipeline = $true, 8 | Mandatory = $true 9 | )] 10 | [System.IO.FileInfo]$Disk, 11 | 12 | [Parameter( 13 | ValuefromPipelineByPropertyName = $true 14 | )] 15 | [Int]$DeleteOlderThanDays, 16 | 17 | [Parameter( 18 | ValuefromPipelineByPropertyName = $true 19 | )] 20 | [Int]$IgnoreLessThanGB, 21 | 22 | [Parameter( 23 | ValuefromPipelineByPropertyName = $true 24 | )] 25 | [double]$RatioFreeSpace = 0.05, 26 | 27 | [Parameter( 28 | ValuefromPipelineByPropertyName = $true 29 | )] 30 | [int]$MountTimeout = 30, 31 | 32 | [Parameter( 33 | ValuefromPipelineByPropertyName = $true 34 | )] 35 | [string]$LogFilePath = "$env:TEMP\FslShrinkDisk $(Get-Date -Format yyyy-MM-dd` HH-mm-ss).csv", 36 | 37 | [Parameter( 38 | ValuefromPipelineByPropertyName = $true 39 | )] 40 | [switch]$Passthru 41 | 42 | ) 43 | 44 | BEGIN { 45 | #Requires -RunAsAdministrator 46 | Set-StrictMode -Version Latest 47 | $hyperv = $false 48 | } # Begin 49 | PROCESS { 50 | #In case there are disks left mounted let's try to clean up. 51 | Dismount-DiskImage -ImagePath $Disk.FullName -ErrorAction SilentlyContinue 52 | 53 | #Get start time for logfile 54 | $startTime = Get-Date 55 | if ( $IgnoreLessThanGB ) { 56 | $IgnoreLessThanBytes = $IgnoreLessThanGB * 1024 * 1024 * 1024 57 | } 58 | 59 | #Grab size of disk being processed 60 | $originalSize = $Disk.Length 61 | 62 | #Set default parameter values for the Write-VhdOutput command to prevent repeating code below, these can be overridden as I need to. Calclations to be done in the output function, raw data goes in. 63 | $PSDefaultParameterValues = @{ 64 | "Write-VhdOutput:Path" = $LogFilePath 65 | "Write-VhdOutput:StartTime" = $startTime 66 | "Write-VhdOutput:Name" = $Disk.Name 67 | "Write-VhdOutput:DiskState" = $null 68 | "Write-VhdOutput:OriginalSize" = $originalSize 69 | "Write-VhdOutput:FinalSize" = $originalSize 70 | "Write-VhdOutput:FullName" = $Disk.FullName 71 | "Write-VhdOutput:Passthru" = $Passthru 72 | } 73 | 74 | #Check it is a disk 75 | if ($Disk.Extension -ne '.vhd' -and $Disk.Extension -ne '.vhdx' ) { 76 | Write-VhdOutput -DiskState 'File Is Not a Virtual Hard Disk format with extension vhd or vhdx' -EndTime (Get-Date) 77 | return 78 | } 79 | 80 | #If it's older than x days delete disk 81 | If ( $DeleteOlderThanDays ) { 82 | #Last Access time isn't always reliable if diff disks are used so lets be safe and use the most recent of access and write 83 | $mostRecent = $Disk.LastAccessTime, $Disk.LastWriteTime | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum 84 | if ($mostRecent -lt (Get-Date).AddDays(-$DeleteOlderThanDays) ) { 85 | try { 86 | Remove-Item $Disk.FullName -ErrorAction Stop -Force 87 | Write-VhdOutput -DiskState "Deleted" -FinalSize 0 -EndTime (Get-Date) 88 | } 89 | catch { 90 | Write-VhdOutput -DiskState 'Disk Deletion Failed' -EndTime (Get-Date) 91 | } 92 | return 93 | } 94 | } 95 | 96 | #As disks take time to process, if you have a lot of disks, it may not be worth shrinking the small onesBytes 97 | if ( $IgnoreLessThanGB -and $originalSize -lt $IgnoreLessThanBytes ) { 98 | Write-VhdOutput -DiskState 'Ignored' -EndTime (Get-Date) 99 | return 100 | } 101 | 102 | #Initial disk Mount 103 | try { 104 | $mount = Mount-FslDisk -Path $Disk.FullName -TimeOut 30 -PassThru -ErrorAction Stop 105 | } 106 | catch { 107 | $err = $error[0] 108 | Write-VhdOutput -DiskState $err -EndTime (Get-Date) 109 | return 110 | } 111 | 112 | #Grabbing partition info can fail when the client is under heavy load so....... 113 | $timespan = (Get-Date).AddSeconds(120) 114 | $partInfo = $null 115 | while (($partInfo | Measure-Object).Count -lt 1 -and $timespan -gt (Get-Date)) { 116 | try { 117 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber -ErrorAction Stop | Where-Object -Property 'Type' -EQ -Value 'Basic' -ErrorAction Stop 118 | } 119 | catch { 120 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber -ErrorAction SilentlyContinue | Select-Object -Last 1 121 | } 122 | Start-Sleep 0.1 123 | } 124 | 125 | if (($partInfo | Measure-Object).Count -eq 0) { 126 | $mount | DisMount-FslDisk 127 | Write-VhdOutput -DiskState 'No Partition Information - The Windows Disk SubSystem did not respond in a timely fashion try increasing number of cores or decreasing threads by using the ThrottleLimit parameter' -EndTime (Get-Date) 128 | return 129 | } 130 | 131 | $timespan = (Get-Date).AddSeconds(120) 132 | $defrag = $false 133 | while ($defrag -eq $false -and $timespan -gt (Get-Date)) { 134 | try { 135 | Get-Volume -Partition $partInfo -ErrorAction Stop | Optimize-Volume -ErrorAction Stop 136 | $defrag = $true 137 | } 138 | catch { 139 | try { 140 | Get-Volume -ErrorAction Stop | Where-Object { 141 | $_.UniqueId -like "*$($partInfo.Guid)*" 142 | -or $_.Path -Like "*$($partInfo.Guid)*" 143 | -or $_.ObjectId -Like "*$($partInfo.Guid)*" } | Optimize-Volume -ErrorAction Stop 144 | $defrag = $true 145 | } 146 | catch { 147 | $defrag = $false 148 | Start-Sleep 0.1 149 | } 150 | $defrag = $false 151 | } 152 | } 153 | 154 | #Grab partition information so we know what size to shrink the partition to and what to re-enlarge it to. This helps optimise-vhd work at it's best 155 | $partSize = $false 156 | $timespan = (Get-Date).AddSeconds(30) 157 | while ($partSize -eq $false -and $timespan -gt (Get-Date)) { 158 | try { 159 | $partitionsize = $partInfo | Get-PartitionSupportedSize -ErrorAction Stop 160 | $sizeMax = $partitionsize.SizeMax 161 | $partSize = $true 162 | } 163 | catch { 164 | try { 165 | $partitionsize = Get-PartitionSupportedSize -DiskNumber $mount.DiskNumber -PartitionNumber $mount.PartitionNumber -ErrorAction Stop 166 | $sizeMax = $partitionsize.SizeMax 167 | $partSize = $true 168 | } 169 | catch { 170 | $partSize = $false 171 | Start-Sleep 0.1 172 | } 173 | $partSize = $false 174 | 175 | } 176 | } 177 | 178 | if ($partSize -eq $false) { 179 | #$partInfo | Export-Clixml -Path "$env:TEMP\ForJim-$($Disk.Name).xml" 180 | Write-VhdOutput -DiskState 'No Partition Supported Size Info - The Windows Disk SubSystem did not respond in a timely fashion try increasing number of cores or decreasing threads by using the ThrottleLimit parameter' -EndTime (Get-Date) 181 | $mount | DisMount-FslDisk 182 | return 183 | } 184 | 185 | 186 | #If you can't shrink the partition much, you can't reclaim a lot of space, so skipping if it's not worth it. Otherwise shink partition and dismount disk 187 | 188 | if ( $partitionsize.SizeMin -gt $disk.Length ) { 189 | Write-VhdOutput -DiskState "SkippedAlreadyMinimum" -EndTime (Get-Date) 190 | $mount | DisMount-FslDisk 191 | return 192 | } 193 | 194 | 195 | if (($partitionsize.SizeMin / $disk.Length) -gt (1 - $RatioFreeSpace) ) { 196 | Write-VhdOutput -DiskState "LessThan$(100*$RatioFreeSpace)%FreeInsideDisk" -EndTime (Get-Date) 197 | $mount | DisMount-FslDisk 198 | return 199 | } 200 | 201 | #If I decide to add Hyper-V module support, I'll need this code later 202 | if ($hyperv -eq $true) { 203 | 204 | #In some cases you can't do the partition shrink to the min so increasing by 100 MB each time till it shrinks 205 | $i = 0 206 | $resize = $false 207 | $targetSize = $partitionsize.SizeMin 208 | $sizeBytesIncrement = 100 * 1024 * 1024 209 | 210 | while ($i -le 5 -and $resize -eq $false) { 211 | try { 212 | Resize-Partition -InputObject $partInfo -Size $targetSize -ErrorAction Stop 213 | $resize = $true 214 | } 215 | catch { 216 | $resize = $false 217 | $targetSize = $targetSize + $sizeBytesIncrement 218 | $i++ 219 | } 220 | finally { 221 | Start-Sleep 1 222 | } 223 | } 224 | 225 | #Whatever happens now we need to dismount 226 | 227 | if ($resize -eq $false) { 228 | Write-VhdOutput -DiskState "PartitionShrinkFailed" -EndTime (Get-Date) 229 | $mount | DisMount-FslDisk 230 | return 231 | } 232 | } 233 | 234 | $mount | DisMount-FslDisk 235 | 236 | #Change the disk size and grab the new size 237 | 238 | $retries = 0 239 | $success = $false 240 | #Diskpart is a little erratic and can fail occasionally, so stuck it in a loop. 241 | while ($retries -lt 30 -and $success -ne $true) { 242 | 243 | $tempFileName = "$env:TEMP\FslDiskPart$($Disk.Name).txt" 244 | 245 | #Let's put diskpart into a function just so I can use Pester to Mock it 246 | function invoke-diskpart ($Path) { 247 | #diskpart needs you to write a txt file so you can automate it, because apparently it's 1989. 248 | #A better way would be to use optimize-vhd from the Hyper-V module, 249 | # but that only comes along with installing the actual role, which needs CPU virtualisation extensions present, 250 | # which is a PITA in cloud and virtualised environments where you can't do Hyper-V. 251 | #MaybeDo, use hyper-V module if it's there if not use diskpart? two code paths to do the same thing probably not smart though, it would be a way to solve localisation issues. 252 | Set-Content -Path $Path -Value "SELECT VDISK FILE=`'$($Disk.FullName)`'" 253 | Add-Content -Path $Path -Value 'attach vdisk readonly' 254 | Add-Content -Path $Path -Value 'COMPACT VDISK' 255 | Add-Content -Path $Path -Value 'detach vdisk' 256 | $result = DISKPART /s $Path 257 | Write-Output $result 258 | } 259 | 260 | $diskPartResult = invoke-diskpart -Path $tempFileName 261 | 262 | #diskpart doesn't return an object (1989 remember) so we have to parse the text output. 263 | if ($diskPartResult -contains 'DiskPart successfully compacted the virtual disk file.') { 264 | $finalSize = Get-ChildItem $Disk.FullName | Select-Object -ExpandProperty Length 265 | $success = $true 266 | Remove-Item $tempFileName 267 | } 268 | else { 269 | Set-Content -Path "$env:TEMP\FslDiskPartError$($Disk.Name)-$retries.log" -Value $diskPartResult 270 | $retries++ 271 | #if DiskPart fails, try, try again. 272 | } 273 | Start-Sleep 1 274 | } 275 | 276 | If ($success -ne $true) { 277 | Write-VhdOutput -DiskState "DiskShrinkFailed" -EndTime (Get-Date) 278 | Remove-Item $tempFileName 279 | return 280 | } 281 | 282 | #If I decide to add Hyper-V module support, I'll need this code later 283 | if ($hyperv -eq $true) { 284 | #Now we need to reinflate the partition to its previous size 285 | try { 286 | $mount = Mount-FslDisk -Path $Disk.FullName -PassThru 287 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber | Where-Object -Property 'Type' -EQ -Value 'Basic' 288 | Resize-Partition -InputObject $partInfo -Size $sizeMax -ErrorAction Stop 289 | $paramWriteVhdOutput = @{ 290 | DiskState = "Success" 291 | FinalSize = $finalSize 292 | EndTime = Get-Date 293 | } 294 | Write-VhdOutput @paramWriteVhdOutput 295 | } 296 | catch { 297 | Write-VhdOutput -DiskState "PartitionSizeRestoreFailed" -EndTime (Get-Date) 298 | return 299 | } 300 | finally { 301 | $mount | DisMount-FslDisk 302 | } 303 | } 304 | 305 | 306 | $paramWriteVhdOutput = @{ 307 | DiskState = "Success" 308 | FinalSize = $finalSize 309 | EndTime = Get-Date 310 | } 311 | Write-VhdOutput @paramWriteVhdOutput 312 | } #Process 313 | END { } #End 314 | } #function Optimize-OneDisk -------------------------------------------------------------------------------- /Functions/Private/Test-FslDependencies.ps1: -------------------------------------------------------------------------------- 1 | Function Test-FslDependencies { 2 | [CmdletBinding()] 3 | Param ( 4 | [Parameter( 5 | Mandatory = $true, 6 | Position = 0, 7 | ValueFromPipelineByPropertyName = $true, 8 | ValueFromPipeline = $true 9 | )] 10 | [System.String[]]$Name 11 | ) 12 | BEGIN { 13 | #Requires -RunAsAdministrator 14 | Set-StrictMode -Version Latest 15 | } 16 | PROCESS { 17 | 18 | Foreach ($svc in $Name) { 19 | $svcObject = Get-Service -Name $svc 20 | 21 | If ($svcObject.Status -eq "Running") { Return } 22 | 23 | If ($svcObject.StartType -eq "Disabled") { 24 | Write-Warning ("[{0}] Setting Service to Manual" -f $svcObject.DisplayName) 25 | Set-Service -Name $svc -StartupType Manual | Out-Null 26 | } 27 | 28 | Start-Service -Name $svc | Out-Null 29 | 30 | if ((Get-Service -Name $svc).Status -ne 'Running') { 31 | Write-Error "Can not start $svcObject.DisplayName" 32 | } 33 | } 34 | } 35 | END { 36 | 37 | } 38 | } -------------------------------------------------------------------------------- /Functions/Private/Write-VhdOutput.ps1: -------------------------------------------------------------------------------- 1 | function Write-VhdOutput { 2 | [CmdletBinding()] 3 | 4 | Param ( 5 | [Parameter( 6 | Mandatory = $true 7 | )] 8 | [System.String]$Path, 9 | 10 | [Parameter( 11 | Mandatory = $true 12 | )] 13 | [System.String]$Name, 14 | 15 | [Parameter( 16 | Mandatory = $true 17 | )] 18 | [System.String]$DiskState, 19 | 20 | [Parameter( 21 | Mandatory = $true 22 | )] 23 | [System.String]$OriginalSize, 24 | 25 | [Parameter( 26 | Mandatory = $true 27 | )] 28 | [System.String]$FinalSize, 29 | 30 | [Parameter( 31 | Mandatory = $true 32 | )] 33 | [System.String]$FullName, 34 | 35 | [Parameter( 36 | Mandatory = $true 37 | )] 38 | [datetime]$StartTime, 39 | 40 | [Parameter( 41 | Mandatory = $true 42 | )] 43 | [datetime]$EndTime, 44 | 45 | [Parameter( 46 | Mandatory = $true 47 | )] 48 | [Switch]$Passthru 49 | ) 50 | 51 | BEGIN { 52 | Set-StrictMode -Version Latest 53 | } # Begin 54 | PROCESS { 55 | 56 | #unit conversion and calculation should happen in output function 57 | $output = [PSCustomObject]@{ 58 | Name = $Name 59 | StartTime = $StartTime.ToLongTimeString() 60 | EndTime = $EndTime.ToLongTimeString() 61 | 'ElapsedTime(s)' = [math]::Round(($EndTime - $StartTime).TotalSeconds, 1) 62 | DiskState = $DiskState 63 | OriginalSizeGB = [math]::Round( $OriginalSize / 1GB, 2 ) 64 | FinalSizeGB = [math]::Round( $FinalSize / 1GB, 2 ) 65 | SpaceSavedGB = [math]::Round( ($OriginalSize - $FinalSize) / 1GB, 2 ) 66 | FullName = $FullName 67 | } 68 | 69 | if ($Passthru) { 70 | Write-Output $output 71 | } 72 | $success = $False 73 | $retries = 0 74 | while ($retries -lt 10 -and $success -ne $true) { 75 | try { 76 | $output | Export-Csv -Path $Path -NoClobber -Append -ErrorAction Stop -NoTypeInformation 77 | $success = $true 78 | } 79 | catch { 80 | $retries++ 81 | } 82 | Start-Sleep 1 83 | } 84 | 85 | 86 | } #Process 87 | END { } #End 88 | } #function Write-VhdOutput.ps1 -------------------------------------------------------------------------------- /Functions/Public/Invoke-FslShrinkDisk.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Shrinks FSLogix Profile and O365 dynamically expanding disk(s). 4 | 5 | .DESCRIPTION 6 | FSLogix profile and O365 virtual hard disks are in the vhd or vhdx file format. By default, the disks created will be in Dynamically Expanding format rather than Fixed format. This script does not support reducing the size of a Fixed file format. 7 | 8 | Dynamically Expanding disks do not natively shrink when the volume of data within them reduces, they stay at the 'High water mark' of historical data volume within them. 9 | 10 | This means that Enterprises can wish to reclaim whitespace inside the disks to keep cost down if the storage is cloud based, or make sure they don’t exceed capacity limits if storage is on-premises. 11 | 12 | This Script is designed to work at Enterprise scale to reduce the size of thousands of disks in the shortest time possible. 13 | This script can be run from any machine in your environment it does not need to be run from a file server hosting the disks. It does not need the Hyper-V role installed. 14 | Powershell version 5.x and 7 and above are supported for this script. It needs to be run as administrator due to the requirement for mounting disks to the OS where the script is run. 15 | This tool is multi-threaded and will take advantage of multiple CPU cores on the machine from which you run the script. It is not advised to run more than 2x the threads of your available cores on your machine. You could also use the number of threads to throttle the load on your storage. 16 | Reducing the size of a virtual hard disk is a storage intensive activity. The activity is more in file system metadata operations than pure IOPS, so make sure your storage controllers can handle the load. The storage load occurs on the location where the disks are stored not on the machine where the script is run from. I advise running the script out of hours if possible, to avoid impacting other users on the storage. 17 | With the intention of reducing the storage load to the minimum possible, you can configure the script to only shrink the disks where you will see the most benefit. You can delete disks which have not been accessed in x number of days previously (configurable). Deletion of disks is not enabled by default. By default the script will not run on any disk with less than 5% whitespace inside (configurable). The script can optionally also not run on disks smaller than xGB (configurable) as it’s possible that even a large % of whitespace in small disks won’t result in a large capacity reclamation, but even shrinking a small amount of capacity will cause storage load. 18 | The script will output a csv in the following format: 19 | 20 | "Name","DiskState","OriginalSizeGB","FinalSizeGB","SpaceSavedGB","FullName" 21 | "Profile_user1.vhdx","Success","4.35","3.22","1.13",\\Server\Share\ Profile_user1.vhdx " 22 | "Profile_user2.vhdx","Success","4.75","3.12","1.63",\\Server\Share\ Profile_user2.vhdx 23 | 24 | Possible Information values for DiskState are as follows: 25 | Success Disk has been successfully processed and shrunk 26 | Ignored Disk was less than the size configured in -IgnoreLessThanGB parameter 27 | Deleted Disk was last accessed before the number of days configured in the -DeleteOlderThanDays parameter and was successfully deleted 28 | DiskLocked Disk could not be mounted due to being in use 29 | LessThan(x)%FreeInsideDisk Disk contained less whitespace than configured in -RatioFreeSpace parameter and was ignored for processing 30 | 31 | Possible Error values for DiskState are as follows: 32 | FileIsNotDiskFormat Disk file extension was not vhd or vhdx 33 | DiskDeletionFailed Disk was last accessed before the number of days configured in the -DeleteOlderThanDays parameter and was not successfully deleted 34 | NoPartitionInfo Could not get partition information for partition 1 from the disk 35 | PartitionShrinkFailed Failed to Optimize partition as part of the disk processing 36 | DiskShrinkFailed Could not shrink Disk 37 | PartitionSizeRestoreFailed Failed to Restore partition as part of the disk processing 38 | 39 | If the diskstate shows an error value from the list above, manual intervention may be required to make the disk usable again. 40 | 41 | If you inspect your environment you will probably see that there are a few disks that are consuming a lot of capacity targeting these by using the minimum disk size configuration would be a good step. To grab a list of disks and their sizes from a share you could use this oneliner by replacing with the path to the share containing the disks. 42 | Get-ChildItem -Path -Filter "*.vhd*" -Recurse -File | Select-Object Name, @{n = 'SizeInGB'; e = {[math]::round($_.length/1GB,2)}} 43 | All this oneliner does is gather the names and sizes of the virtual hard disks from your share. To export this information to a file readable by excel, use the following replacing both and < yourcsvfile.csv>. You can then open the csv file in excel. 44 | Get-ChildItem -Path -Filter "*.vhd*" -Recurse -File | Select-Object Name, @{n = 'SizeInGB'; e = {[math]::round($_.length/1GB,2)}} | Export-Csv -Path < yourcsvfile.csv> 45 | 46 | .NOTES 47 | Whilst I work for Microsoft and used to work for FSLogix, this is not officially released software from either company. This is purely a personal project designed to help the community. If you require support for this tool please raise an issue on the GitHub repository linked below 48 | 49 | .PARAMETER Path 50 | The path to the folder/share containing the disks. You can also directly specify a single disk. UNC paths are supported. 51 | 52 | .PARAMETER Recurse 53 | Gets the disks in the specified locations and in all child items of the locations 54 | 55 | .PARAMETER IgnoreLessThanGB 56 | The disk size in GB under which the script will not process the file. 57 | 58 | .PARAMETER DeleteOlderThanDays 59 | If a disk ‘last access time’ is older than todays date minus this value, the disk will be deleted from the share. This is a permanent action. 60 | 61 | .PARAMETER LogFilePath 62 | All disk actions will be saved in a csv file for admin reference. The default location for this csv file is the user’s temp directory. The default filename is in the following format: FslShrinkDisk 2020-04-14 19-36-19.csv 63 | 64 | .PARAMETER PassThru 65 | Returns an object representing the item with which you are working. By default, this cmdlet does not generate any pipeline output. 66 | 67 | .PARAMETER ThrottleLimit 68 | Specifies the number of disks that will be processed at a time. Further disks in the queue will wait till a previous disk has finished up to a maximum of the ThrottleLimit. The default value is 8. 69 | 70 | .PARAMETER RatioFreeSpace 71 | The minimum percentage of white space in the disk before processing will start as a decimal between 0 and 1 eg 0.2 is 20% 0.65 is 65%. The Default is 0.05. This means that if the available size reduction is less than 5%, then no action will be taken. To try and shrink all files no matter how little the gain set this to 0. 72 | 73 | .INPUTS 74 | You can pipe the path into the command which is recognised by type, you can also pipe any parameter by name. It will also take the path positionally 75 | 76 | .OUTPUTS 77 | This script outputs a csv file with the result of the disk processing. It will optionally produce a custom object with the same information 78 | 79 | .EXAMPLE 80 | C:\PS> Invoke-FslShrinkDisk -Path c:\Profile_user1.vhdx 81 | This shrinks a single disk on the local file system 82 | 83 | .EXAMPLE 84 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse 85 | This shrinks all disks in the specified share recursively 86 | 87 | .EXAMPLE 88 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -IgnoreLessThanGB 3 89 | This shrinks all disks in the specified share recursively, except for files under 3GB in size which it ignores. 90 | 91 | .EXAMPLE 92 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -DeleteOlderThanDays 90 93 | This shrinks all disks in the specified share recursively and deletes disks which were not accessed within the last 90 days. 94 | 95 | .EXAMPLE 96 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -LogFilePath C:\MyLogFile.csv 97 | This shrinks all disks in the specified share recursively and changes the default log file location to C:\MyLogFile.csv 98 | 99 | .EXAMPLE 100 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -PassThru 101 | Name: Profile_user1.vhdx 102 | DiskState: Success 103 | OriginalSizeGB: 4.35 104 | FinalSizeGB: 3.22 105 | SpaceSavedGB: 1.13 106 | FullName: \\Server\Share\ Profile_user1.vhdx 107 | This shrinks all disks in the specified share recursively and passes the result of the disk processing to the pipeline as an object. 108 | 109 | .EXAMPLE 110 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -ThrottleLimit 20 111 | This shrinks all disks in the specified share recursively increasing the number of threads used to 20 from the default 8. 112 | 113 | .EXAMPLE 114 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -RatioFreeSpace 0.3 115 | This shrinks all disks in the specified share recursively while not processing disks which have less than 30% whitespace instead of the default 15%. 116 | 117 | .LINK 118 | https://github.com/FSLogix/Invoke-FslShrinkDisk/ 119 | 120 | #> 121 | 122 | [CmdletBinding()] 123 | 124 | Param ( 125 | 126 | [Parameter( 127 | Position = 1, 128 | ValuefromPipelineByPropertyName = $true, 129 | ValuefromPipeline = $true, 130 | Mandatory = $true 131 | )] 132 | [System.String]$Path, 133 | 134 | [Parameter( 135 | ValuefromPipelineByPropertyName = $true 136 | )] 137 | [double]$IgnoreLessThanGB = 0, 138 | 139 | [Parameter( 140 | ValuefromPipelineByPropertyName = $true 141 | )] 142 | [int]$DeleteOlderThanDays, 143 | 144 | [Parameter( 145 | ValuefromPipelineByPropertyName = $true 146 | )] 147 | [Switch]$Recurse, 148 | 149 | [Parameter( 150 | ValuefromPipelineByPropertyName = $true 151 | )] 152 | [System.String]$LogFilePath = "$env:TEMP\FslShrinkDisk $(Get-Date -Format yyyy-MM-dd` HH-mm-ss).csv", 153 | 154 | [Parameter( 155 | ValuefromPipelineByPropertyName = $true 156 | )] 157 | [switch]$PassThru, 158 | 159 | [Parameter( 160 | ValuefromPipelineByPropertyName = $true 161 | )] 162 | [int]$ThrottleLimit = 8, 163 | 164 | [Parameter( 165 | ValuefromPipelineByPropertyName = $true 166 | )] 167 | [ValidateRange(0,1)] 168 | [double]$RatioFreeSpace = 0.05 169 | ) 170 | 171 | BEGIN { 172 | Set-StrictMode -Version Latest 173 | #Requires -RunAsAdministrator 174 | 175 | #Test-FslDependencies 176 | . .\Functions\Private\Test-FslDependencies.ps1 177 | 178 | #Invoke-Parallel - This is used to support powershell 5.x - if and when PoSh 7 and above become standard, move to ForEach-Object 179 | . .\Functions\Private\Invoke-Parallel.ps1 180 | 181 | #Mount-FslDisk 182 | . .\Functions\Private\Mount-FslDisk.ps1 183 | 184 | #Dismount-FslDisk 185 | . .\Functions\Private\Dismount-FslDisk.ps1 186 | 187 | #Optimize-OneDisk 188 | . .\Functions\Private\Optimize-OneDisk.ps1 189 | 190 | #Write Output to file and optionally to pipeline 191 | . .\Functions\Private\Write-VhdOutput.ps1 192 | 193 | $servicesToTest = 'defragsvc', 'vds' 194 | try{ 195 | $servicesToTest | Test-FslDependencies -ErrorAction Stop 196 | } 197 | catch{ 198 | $err = $error[0] 199 | Write-Error $err 200 | return 201 | } 202 | $numberOfCores = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors 203 | 204 | If (($ThrottleLimit / 2) -gt $numberOfCores) { 205 | 206 | $ThrottleLimit = $numberOfCores * 2 207 | Write-Warning "Number of threads set to double the number of cores - $ThrottleLimit" 208 | } 209 | 210 | } # Begin 211 | PROCESS { 212 | 213 | #Check that the path is valid 214 | if (-not (Test-Path $Path)) { 215 | Write-Error "$Path not found" 216 | return 217 | } 218 | 219 | #Get a list of Virtual Hard Disk files depending on the recurse parameter 220 | if ($Recurse) { 221 | $diskList = Get-ChildItem -File -Filter *.vhd? -Path $Path -Recurse 222 | } 223 | else { 224 | $diskList = Get-ChildItem -File -Filter *.vhd? -Path $Path 225 | } 226 | 227 | $diskList = $diskList | Where-Object { $_.Name -ne "Merge.vhdx" -and $_.Name -ne "RW.vhdx" } 228 | 229 | #If we can't find and files with the extension vhd or vhdx quit 230 | if ( ($diskList | Measure-Object).count -eq 0 ) { 231 | Write-Warning "No files to process in $Path" 232 | return 233 | } 234 | 235 | $scriptblockForEachObject = { 236 | 237 | #ForEach-Object -Parallel doesn't seem to want to import functions, so defining them twice, good job this is automated. 238 | 239 | #Mount-FslDisk 240 | . .\Functions\Private\Mount-FslDisk.ps1 241 | #Dismount-FslDisk 242 | . .\Functions\Private\Dismount-FslDisk.ps1 243 | #Optimize-OneDisk 244 | . .\Functions\Private\Optimize-OneDisk.ps1 245 | #Write Output to file and optionally to pipeline 246 | . .\Functions\Private\Write-VhdOutput.ps1 247 | 248 | $paramOptimizeOneDisk = @{ 249 | Disk = $_ 250 | DeleteOlderThanDays = $using:DeleteOlderThanDays 251 | IgnoreLessThanGB = $using:IgnoreLessThanGB 252 | LogFilePath = $using:LogFilePath 253 | PassThru = $using:PassThru 254 | RatioFreeSpace = $using:RatioFreeSpace 255 | } 256 | Optimize-OneDisk @paramOptimizeOneDisk 257 | 258 | } #Scriptblock 259 | 260 | $scriptblockInvokeParallel = { 261 | 262 | $disk = $_ 263 | 264 | $paramOptimizeOneDisk = @{ 265 | Disk = $disk 266 | DeleteOlderThanDays = $DeleteOlderThanDays 267 | IgnoreLessThanGB = $IgnoreLessThanGB 268 | LogFilePath = $LogFilePath 269 | PassThru = $PassThru 270 | RatioFreeSpace = $RatioFreeSpace 271 | } 272 | Optimize-OneDisk @paramOptimizeOneDisk 273 | 274 | } #Scriptblock 275 | 276 | if ($PSVersionTable.PSVersion -ge [version]"7.0") { 277 | $diskList | ForEach-Object -Parallel $scriptblockForEachObject -ThrottleLimit $ThrottleLimit 278 | } 279 | else { 280 | $diskList | Invoke-Parallel -ScriptBlock $scriptblockInvokeParallel -Throttle $ThrottleLimit -ImportFunctions -ImportVariables -ImportModules 281 | } 282 | 283 | } #Process 284 | END { } #End -------------------------------------------------------------------------------- /Invoke-FslShrinkDisk.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Shrinks FSLogix Profile and O365 dynamically expanding disk(s). 4 | 5 | .DESCRIPTION 6 | FSLogix profile and O365 virtual hard disks are in the vhd or vhdx file format. By default, the disks created will be in Dynamically Expanding format rather than Fixed format. This script does not support reducing the size of a Fixed file format. 7 | 8 | Dynamically Expanding disks do not natively shrink when the volume of data within them reduces, they stay at the 'High water mark' of historical data volume within them. 9 | 10 | This means that Enterprises can wish to reclaim whitespace inside the disks to keep cost down if the storage is cloud based, or make sure they don’t exceed capacity limits if storage is on-premises. 11 | 12 | This Script is designed to work at Enterprise scale to reduce the size of thousands of disks in the shortest time possible. 13 | This script can be run from any machine in your environment it does not need to be run from a file server hosting the disks. It does not need the Hyper-V role installed. 14 | Powershell version 5.x and 7 and above are supported for this script. It needs to be run as administrator due to the requirement for mounting disks to the OS where the script is run. 15 | This tool is multi-threaded and will take advantage of multiple CPU cores on the machine from which you run the script. It is not advised to run more than 2x the threads of your available cores on your machine. You could also use the number of threads to throttle the load on your storage. 16 | Reducing the size of a virtual hard disk is a storage intensive activity. The activity is more in file system metadata operations than pure IOPS, so make sure your storage controllers can handle the load. The storage load occurs on the location where the disks are stored not on the machine where the script is run from. I advise running the script out of hours if possible, to avoid impacting other users on the storage. 17 | With the intention of reducing the storage load to the minimum possible, you can configure the script to only shrink the disks where you will see the most benefit. You can delete disks which have not been accessed in x number of days previously (configurable). Deletion of disks is not enabled by default. By default the script will not run on any disk with less than 5% whitespace inside (configurable). The script can optionally also not run on disks smaller than xGB (configurable) as it’s possible that even a large % of whitespace in small disks won’t result in a large capacity reclamation, but even shrinking a small amount of capacity will cause storage load. 18 | The script will output a csv in the following format: 19 | 20 | "Name","DiskState","OriginalSizeGB","FinalSizeGB","SpaceSavedGB","FullName" 21 | "Profile_user1.vhdx","Success","4.35","3.22","1.13",\\Server\Share\ Profile_user1.vhdx " 22 | "Profile_user2.vhdx","Success","4.75","3.12","1.63",\\Server\Share\ Profile_user2.vhdx 23 | 24 | Possible Information values for DiskState are as follows: 25 | Success Disk has been successfully processed and shrunk 26 | Ignored Disk was less than the size configured in -IgnoreLessThanGB parameter 27 | Deleted Disk was last accessed before the number of days configured in the -DeleteOlderThanDays parameter and was successfully deleted 28 | DiskLocked Disk could not be mounted due to being in use 29 | LessThan(x)%FreeInsideDisk Disk contained less whitespace than configured in -RatioFreeSpace parameter and was ignored for processing 30 | 31 | Possible Error values for DiskState are as follows: 32 | FileIsNotDiskFormat Disk file extension was not vhd or vhdx 33 | DiskDeletionFailed Disk was last accessed before the number of days configured in the -DeleteOlderThanDays parameter and was not successfully deleted 34 | NoPartitionInfo Could not get partition information for partition 1 from the disk 35 | PartitionShrinkFailed Failed to Optimize partition as part of the disk processing 36 | DiskShrinkFailed Could not shrink Disk 37 | PartitionSizeRestoreFailed Failed to Restore partition as part of the disk processing 38 | 39 | If the diskstate shows an error value from the list above, manual intervention may be required to make the disk usable again. 40 | 41 | If you inspect your environment you will probably see that there are a few disks that are consuming a lot of capacity targeting these by using the minimum disk size configuration would be a good step. To grab a list of disks and their sizes from a share you could use this oneliner by replacing with the path to the share containing the disks. 42 | Get-ChildItem -Path -Filter "*.vhd*" -Recurse -File | Select-Object Name, @{n = 'SizeInGB'; e = {[math]::round($_.length/1GB,2)}} 43 | All this oneliner does is gather the names and sizes of the virtual hard disks from your share. To export this information to a file readable by excel, use the following replacing both and < yourcsvfile.csv>. You can then open the csv file in excel. 44 | Get-ChildItem -Path -Filter "*.vhd*" -Recurse -File | Select-Object Name, @{n = 'SizeInGB'; e = {[math]::round($_.length/1GB,2)}} | Export-Csv -Path < yourcsvfile.csv> 45 | 46 | .NOTES 47 | Whilst I work for Microsoft and used to work for FSLogix, this is not officially released software from either company. This is purely a personal project designed to help the community. If you require support for this tool please raise an issue on the GitHub repository linked below 48 | 49 | .PARAMETER Path 50 | The path to the folder/share containing the disks. You can also directly specify a single disk. UNC paths are supported. 51 | 52 | .PARAMETER Recurse 53 | Gets the disks in the specified locations and in all child items of the locations 54 | 55 | .PARAMETER IgnoreLessThanGB 56 | The disk size in GB under which the script will not process the file. 57 | 58 | .PARAMETER DeleteOlderThanDays 59 | If a disk ‘last access time’ is older than todays date minus this value, the disk will be deleted from the share. This is a permanent action. 60 | 61 | .PARAMETER LogFilePath 62 | All disk actions will be saved in a csv file for admin reference. The default location for this csv file is the user’s temp directory. The default filename is in the following format: FslShrinkDisk 2020-04-14 19-36-19.csv 63 | 64 | .PARAMETER PassThru 65 | Returns an object representing the item with which you are working. By default, this cmdlet does not generate any pipeline output. 66 | 67 | .PARAMETER ThrottleLimit 68 | Specifies the number of disks that will be processed at a time. Further disks in the queue will wait till a previous disk has finished up to a maximum of the ThrottleLimit. The default value is 8. 69 | 70 | .PARAMETER RatioFreeSpace 71 | The minimum percentage of white space in the disk before processing will start as a decimal between 0 and 1 eg 0.2 is 20% 0.65 is 65%. The Default is 0.05. This means that if the available size reduction is less than 5%, then no action will be taken. To try and shrink all files no matter how little the gain set this to 0. 72 | 73 | .INPUTS 74 | You can pipe the path into the command which is recognised by type, you can also pipe any parameter by name. It will also take the path positionally 75 | 76 | .OUTPUTS 77 | This script outputs a csv file with the result of the disk processing. It will optionally produce a custom object with the same information 78 | 79 | .EXAMPLE 80 | C:\PS> Invoke-FslShrinkDisk -Path c:\Profile_user1.vhdx 81 | This shrinks a single disk on the local file system 82 | 83 | .EXAMPLE 84 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse 85 | This shrinks all disks in the specified share recursively 86 | 87 | .EXAMPLE 88 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -IgnoreLessThanGB 3 89 | This shrinks all disks in the specified share recursively, except for files under 3GB in size which it ignores. 90 | 91 | .EXAMPLE 92 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -DeleteOlderThanDays 90 93 | This shrinks all disks in the specified share recursively and deletes disks which were not accessed within the last 90 days. 94 | 95 | .EXAMPLE 96 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -LogFilePath C:\MyLogFile.csv 97 | This shrinks all disks in the specified share recursively and changes the default log file location to C:\MyLogFile.csv 98 | 99 | .EXAMPLE 100 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -PassThru 101 | Name: Profile_user1.vhdx 102 | DiskState: Success 103 | OriginalSizeGB: 4.35 104 | FinalSizeGB: 3.22 105 | SpaceSavedGB: 1.13 106 | FullName: \\Server\Share\ Profile_user1.vhdx 107 | This shrinks all disks in the specified share recursively and passes the result of the disk processing to the pipeline as an object. 108 | 109 | .EXAMPLE 110 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -ThrottleLimit 20 111 | This shrinks all disks in the specified share recursively increasing the number of threads used to 20 from the default 8. 112 | 113 | .EXAMPLE 114 | C:\PS> Invoke-FslShrinkDisk -Path \\server\share -Recurse -RatioFreeSpace 0.3 115 | This shrinks all disks in the specified share recursively while not processing disks which have less than 30% whitespace instead of the default 15%. 116 | 117 | .LINK 118 | https://github.com/FSLogix/Invoke-FslShrinkDisk/ 119 | 120 | #> 121 | 122 | [CmdletBinding()] 123 | 124 | Param ( 125 | 126 | [Parameter( 127 | Position = 1, 128 | ValuefromPipelineByPropertyName = $true, 129 | ValuefromPipeline = $true, 130 | Mandatory = $true 131 | )] 132 | [System.String]$Path, 133 | 134 | [Parameter( 135 | ValuefromPipelineByPropertyName = $true 136 | )] 137 | [double]$IgnoreLessThanGB = 0, 138 | 139 | [Parameter( 140 | ValuefromPipelineByPropertyName = $true 141 | )] 142 | [int]$DeleteOlderThanDays, 143 | 144 | [Parameter( 145 | ValuefromPipelineByPropertyName = $true 146 | )] 147 | [Switch]$Recurse, 148 | 149 | [Parameter( 150 | ValuefromPipelineByPropertyName = $true 151 | )] 152 | [System.String]$LogFilePath = "$env:TEMP\FslShrinkDisk $(Get-Date -Format yyyy-MM-dd` HH-mm-ss).csv", 153 | 154 | [Parameter( 155 | ValuefromPipelineByPropertyName = $true 156 | )] 157 | [switch]$PassThru, 158 | 159 | [Parameter( 160 | ValuefromPipelineByPropertyName = $true 161 | )] 162 | [int]$ThrottleLimit = 8, 163 | 164 | [Parameter( 165 | ValuefromPipelineByPropertyName = $true 166 | )] 167 | [ValidateRange(0,1)] 168 | [double]$RatioFreeSpace = 0.05 169 | ) 170 | 171 | BEGIN { 172 | Set-StrictMode -Version Latest 173 | #Requires -RunAsAdministrator 174 | 175 | #Test-FslDependencies 176 | Function Test-FslDependencies { 177 | [CmdletBinding()] 178 | Param ( 179 | [Parameter( 180 | Mandatory = $true, 181 | Position = 0, 182 | ValueFromPipelineByPropertyName = $true, 183 | ValueFromPipeline = $true 184 | )] 185 | [System.String[]]$Name 186 | ) 187 | BEGIN { 188 | #Requires -RunAsAdministrator 189 | Set-StrictMode -Version Latest 190 | } 191 | PROCESS { 192 | 193 | Foreach ($svc in $Name) { 194 | $svcObject = Get-Service -Name $svc 195 | 196 | If ($svcObject.Status -eq "Running") { Return } 197 | 198 | If ($svcObject.StartType -eq "Disabled") { 199 | Write-Warning ("[{0}] Setting Service to Manual" -f $svcObject.DisplayName) 200 | Set-Service -Name $svc -StartupType Manual | Out-Null 201 | } 202 | 203 | Start-Service -Name $svc | Out-Null 204 | 205 | if ((Get-Service -Name $svc).Status -ne 'Running') { 206 | Write-Error "Can not start $svcObject.DisplayName" 207 | } 208 | } 209 | } 210 | END { 211 | 212 | } 213 | } 214 | 215 | #Invoke-Parallel - This is used to support powershell 5.x - if and when PoSh 7 and above become standard, move to ForEach-Object 216 | function Invoke-Parallel { 217 | <# 218 | .SYNOPSIS 219 | Function to control parallel processing using runspaces 220 | 221 | .DESCRIPTION 222 | Function to control parallel processing using runspaces 223 | 224 | Note that each runspace will not have access to variables and commands loaded in your session or in other runspaces by default. 225 | This behaviour can be changed with parameters. 226 | 227 | .PARAMETER ScriptFile 228 | File to run against all input objects. Must include parameter to take in the input object, or use $args. Optionally, include parameter to take in parameter. Example: C:\script.ps1 229 | 230 | .PARAMETER ScriptBlock 231 | Scriptblock to run against all computers. 232 | 233 | You may use $Using: language in PowerShell 3 and later. 234 | 235 | The parameter block is added for you, allowing behaviour similar to foreach-object: 236 | Refer to the input object as $_. 237 | Refer to the parameter parameter as $parameter 238 | 239 | .PARAMETER InputObject 240 | Run script against these specified objects. 241 | 242 | .PARAMETER Parameter 243 | This object is passed to every script block. You can use it to pass information to the script block; for example, the path to a logging folder 244 | 245 | Reference this object as $parameter if using the scriptblock parameterset. 246 | 247 | .PARAMETER ImportVariables 248 | If specified, get user session variables and add them to the initial session state 249 | 250 | .PARAMETER ImportModules 251 | If specified, get loaded modules and pssnapins, add them to the initial session state 252 | 253 | .PARAMETER Throttle 254 | Maximum number of threads to run at a single time. 255 | 256 | .PARAMETER SleepTimer 257 | Milliseconds to sleep after checking for completed runspaces and in a few other spots. I would not recommend dropping below 200 or increasing above 500 258 | 259 | .PARAMETER RunspaceTimeout 260 | Maximum time in seconds a single thread can run. If execution of your code takes longer than this, it is disposed. Default: 0 (seconds) 261 | 262 | WARNING: Using this parameter requires that maxQueue be set to throttle (it will be by default) for accurate timing. Details here: 263 | http://gallery.technet.microsoft.com/Run-Parallel-Parallel-377fd430 264 | 265 | .PARAMETER NoCloseOnTimeout 266 | Do not dispose of timed out tasks or attempt to close the runspace if threads have timed out. This will prevent the script from hanging in certain situations where threads become non-responsive, at the expense of leaking memory within the PowerShell host. 267 | 268 | .PARAMETER MaxQueue 269 | Maximum number of powershell instances to add to runspace pool. If this is higher than $throttle, $timeout will be inaccurate 270 | 271 | If this is equal or less than throttle, there will be a performance impact 272 | 273 | The default value is $throttle times 3, if $runspaceTimeout is not specified 274 | The default value is $throttle, if $runspaceTimeout is specified 275 | 276 | .PARAMETER LogFile 277 | Path to a file where we can log results, including run time for each thread, whether it completes, completes with errors, or times out. 278 | 279 | .PARAMETER AppendLog 280 | Append to existing log 281 | 282 | .PARAMETER Quiet 283 | Disable progress bar 284 | 285 | .EXAMPLE 286 | Each example uses Test-ForPacs.ps1 which includes the following code: 287 | param($computer) 288 | 289 | if(test-connection $computer -count 1 -quiet -BufferSize 16){ 290 | $object = [pscustomobject] @{ 291 | Computer=$computer; 292 | Available=1; 293 | Kodak=$( 294 | if((test-path "\\$computer\c$\users\public\desktop\Kodak Direct View Pacs.url") -or (test-path "\\$computer\c$\documents and settings\all users\desktop\Kodak Direct View Pacs.url") ){"1"}else{"0"} 295 | ) 296 | } 297 | } 298 | else{ 299 | $object = [pscustomobject] @{ 300 | Computer=$computer; 301 | Available=0; 302 | Kodak="NA" 303 | } 304 | } 305 | 306 | $object 307 | 308 | .EXAMPLE 309 | Invoke-Parallel -scriptfile C:\public\Test-ForPacs.ps1 -inputobject $(get-content C:\pcs.txt) -runspaceTimeout 10 -throttle 10 310 | 311 | Pulls list of PCs from C:\pcs.txt, 312 | Runs Test-ForPacs against each 313 | If any query takes longer than 10 seconds, it is disposed 314 | Only run 10 threads at a time 315 | 316 | .EXAMPLE 317 | Invoke-Parallel -scriptfile C:\public\Test-ForPacs.ps1 -inputobject c-is-ts-91, c-is-ts-95 318 | 319 | Runs against c-is-ts-91, c-is-ts-95 (-computername) 320 | Runs Test-ForPacs against each 321 | 322 | .EXAMPLE 323 | $stuff = [pscustomobject] @{ 324 | ContentFile = "windows\system32\drivers\etc\hosts" 325 | Logfile = "C:\temp\log.txt" 326 | } 327 | 328 | $computers | Invoke-Parallel -parameter $stuff { 329 | $contentFile = join-path "\\$_\c$" $parameter.contentfile 330 | Get-Content $contentFile | 331 | set-content $parameter.logfile 332 | } 333 | 334 | This example uses the parameter argument. This parameter is a single object. To pass multiple items into the script block, we create a custom object (using a PowerShell v3 language) with properties we want to pass in. 335 | 336 | Inside the script block, $parameter is used to reference this parameter object. This example sets a content file, gets content from that file, and sets it to a predefined log file. 337 | 338 | .EXAMPLE 339 | $test = 5 340 | 1..2 | Invoke-Parallel -ImportVariables {$_ * $test} 341 | 342 | Add variables from the current session to the session state. Without -ImportVariables $Test would not be accessible 343 | 344 | .EXAMPLE 345 | $test = 5 346 | 1..2 | Invoke-Parallel {$_ * $Using:test} 347 | 348 | Reference a variable from the current session with the $Using: syntax. Requires PowerShell 3 or later. Note that -ImportVariables parameter is no longer necessary. 349 | 350 | .FUNCTIONALITY 351 | PowerShell Language 352 | 353 | .NOTES 354 | Credit to Boe Prox for the base runspace code and $Using implementation 355 | http://learn-powershell.net/2012/05/10/speedy-network-information-query-using-powershell/ 356 | http://gallery.technet.microsoft.com/scriptcenter/Speedy-Network-Information-5b1406fb#content 357 | https://github.com/proxb/PoshRSJob/ 358 | 359 | Credit to T Bryce Yehl for the Quiet and NoCloseOnTimeout implementations 360 | 361 | Credit to Sergei Vorobev for the many ideas and contributions that have improved functionality, reliability, and ease of use 362 | 363 | .LINK 364 | https://github.com/RamblingCookieMonster/Invoke-Parallel 365 | #> 366 | [cmdletbinding(DefaultParameterSetName = 'ScriptBlock')] 367 | Param ( 368 | [Parameter(Mandatory = $false, position = 0, ParameterSetName = 'ScriptBlock')] 369 | [System.Management.Automation.ScriptBlock]$ScriptBlock, 370 | 371 | [Parameter(Mandatory = $false, ParameterSetName = 'ScriptFile')] 372 | [ValidateScript( { Test-Path $_ -pathtype leaf })] 373 | $ScriptFile, 374 | 375 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 376 | [Alias('CN', '__Server', 'IPAddress', 'Server', 'ComputerName')] 377 | [PSObject]$InputObject, 378 | 379 | [PSObject]$Parameter, 380 | 381 | [switch]$ImportVariables, 382 | [switch]$ImportModules, 383 | [switch]$ImportFunctions, 384 | 385 | [int]$Throttle = 20, 386 | [int]$SleepTimer = 200, 387 | [int]$RunspaceTimeout = 0, 388 | [switch]$NoCloseOnTimeout = $false, 389 | [int]$MaxQueue, 390 | 391 | [validatescript( { Test-Path (Split-Path $_ -parent) })] 392 | [switch] $AppendLog = $false, 393 | [string]$LogFile, 394 | 395 | [switch] $Quiet = $false 396 | ) 397 | begin { 398 | #No max queue specified? Estimate one. 399 | #We use the script scope to resolve an odd PowerShell 2 issue where MaxQueue isn't seen later in the function 400 | if ( -not $PSBoundParameters.ContainsKey('MaxQueue') ) { 401 | if ($RunspaceTimeout -ne 0) { $script:MaxQueue = $Throttle } 402 | else { $script:MaxQueue = $Throttle * 3 } 403 | } 404 | else { 405 | $script:MaxQueue = $MaxQueue 406 | } 407 | $ProgressId = Get-Random 408 | Write-Verbose "Throttle: '$throttle' SleepTimer '$sleepTimer' runSpaceTimeout '$runspaceTimeout' maxQueue '$maxQueue' logFile '$logFile'" 409 | 410 | #If they want to import variables or modules, create a clean runspace, get loaded items, use those to exclude items 411 | if ($ImportVariables -or $ImportModules -or $ImportFunctions) { 412 | $StandardUserEnv = [powershell]::Create().addscript( { 413 | 414 | #Get modules, snapins, functions in this clean runspace 415 | $Modules = Get-Module | Select-Object -ExpandProperty Name 416 | $Snapins = Get-PSSnapin | Select-Object -ExpandProperty Name 417 | $Functions = Get-ChildItem function:\ | Select-Object -ExpandProperty Name 418 | 419 | #Get variables in this clean runspace 420 | #Called last to get vars like $? into session 421 | $Variables = Get-Variable | Select-Object -ExpandProperty Name 422 | 423 | #Return a hashtable where we can access each. 424 | @{ 425 | Variables = $Variables 426 | Modules = $Modules 427 | Snapins = $Snapins 428 | Functions = $Functions 429 | } 430 | }, $true).invoke()[0] 431 | 432 | if ($ImportVariables) { 433 | #Exclude common parameters, bound parameters, and automatic variables 434 | Function _temp { [cmdletbinding(SupportsShouldProcess = $True)] param() } 435 | $VariablesToExclude = @( (Get-Command _temp | Select-Object -ExpandProperty parameters).Keys + $PSBoundParameters.Keys + $StandardUserEnv.Variables ) 436 | Write-Verbose "Excluding variables $( ($VariablesToExclude | Sort-Object ) -join ", ")" 437 | 438 | # we don't use 'Get-Variable -Exclude', because it uses regexps. 439 | # One of the veriables that we pass is '$?'. 440 | # There could be other variables with such problems. 441 | # Scope 2 required if we move to a real module 442 | $UserVariables = @( Get-Variable | Where-Object { -not ($VariablesToExclude -contains $_.Name) } ) 443 | Write-Verbose "Found variables to import: $( ($UserVariables | Select-Object -expandproperty Name | Sort-Object ) -join ", " | Out-String).`n" 444 | } 445 | if ($ImportModules) { 446 | $UserModules = @( Get-Module | Where-Object { $StandardUserEnv.Modules -notcontains $_.Name -and (Test-Path $_.Path -ErrorAction SilentlyContinue) } | Select-Object -ExpandProperty Path ) 447 | $UserSnapins = @( Get-PSSnapin | Select-Object -ExpandProperty Name | Where-Object { $StandardUserEnv.Snapins -notcontains $_ } ) 448 | } 449 | if ($ImportFunctions) { 450 | $UserFunctions = @( Get-ChildItem function:\ | Where-Object { $StandardUserEnv.Functions -notcontains $_.Name } ) 451 | } 452 | } 453 | 454 | #region functions 455 | Function Get-RunspaceData { 456 | [cmdletbinding()] 457 | param( [switch]$Wait ) 458 | #loop through runspaces 459 | #if $wait is specified, keep looping until all complete 460 | Do { 461 | #set more to false for tracking completion 462 | $more = $false 463 | 464 | #Progress bar if we have inputobject count (bound parameter) 465 | if (-not $Quiet) { 466 | Write-Progress -Id $ProgressId -Activity "Running Query" -Status "Starting threads"` 467 | -CurrentOperation "$startedCount threads defined - $totalCount input objects - $script:completedCount input objects processed"` 468 | -PercentComplete $( Try { $script:completedCount / $totalCount * 100 } Catch { 0 } ) 469 | } 470 | 471 | #run through each runspace. 472 | Foreach ($runspace in $runspaces) { 473 | 474 | #get the duration - inaccurate 475 | $currentdate = Get-Date 476 | $runtime = $currentdate - $runspace.startTime 477 | $runMin = [math]::Round( $runtime.totalminutes , 2 ) 478 | 479 | #set up log object 480 | $log = "" | Select-Object Date, Action, Runtime, Status, Details 481 | $log.Action = "Removing:'$($runspace.object)'" 482 | $log.Date = $currentdate 483 | $log.Runtime = "$runMin minutes" 484 | 485 | #If runspace completed, end invoke, dispose, recycle, counter++ 486 | If ($runspace.Runspace.isCompleted) { 487 | 488 | $script:completedCount++ 489 | 490 | #check if there were errors 491 | if ($runspace.powershell.Streams.Error.Count -gt 0) { 492 | #set the logging info and move the file to completed 493 | $log.status = "CompletedWithErrors" 494 | Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] 495 | foreach ($ErrorRecord in $runspace.powershell.Streams.Error) { 496 | Write-Error -ErrorRecord $ErrorRecord 497 | } 498 | } 499 | else { 500 | #add logging details and cleanup 501 | $log.status = "Completed" 502 | Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] 503 | } 504 | 505 | #everything is logged, clean up the runspace 506 | $runspace.powershell.EndInvoke($runspace.Runspace) 507 | $runspace.powershell.dispose() 508 | $runspace.Runspace = $null 509 | $runspace.powershell = $null 510 | } 511 | #If runtime exceeds max, dispose the runspace 512 | ElseIf ( $runspaceTimeout -ne 0 -and $runtime.totalseconds -gt $runspaceTimeout) { 513 | $script:completedCount++ 514 | $timedOutTasks = $true 515 | 516 | #add logging details and cleanup 517 | $log.status = "TimedOut" 518 | Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] 519 | Write-Error "Runspace timed out at $($runtime.totalseconds) seconds for the object:`n$($runspace.object | out-string)" 520 | 521 | #Depending on how it hangs, we could still get stuck here as dispose calls a synchronous method on the powershell instance 522 | if (!$noCloseOnTimeout) { $runspace.powershell.dispose() } 523 | $runspace.Runspace = $null 524 | $runspace.powershell = $null 525 | $completedCount++ 526 | } 527 | 528 | #If runspace isn't null set more to true 529 | ElseIf ($runspace.Runspace -ne $null ) { 530 | $log = $null 531 | $more = $true 532 | } 533 | 534 | #log the results if a log file was indicated 535 | if ($logFile -and $log) { 536 | ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] | out-file $LogFile -append 537 | } 538 | } 539 | 540 | #Clean out unused runspace jobs 541 | $temphash = $runspaces.clone() 542 | $temphash | Where-Object { $_.runspace -eq $Null } | ForEach-Object { 543 | $Runspaces.remove($_) 544 | } 545 | 546 | #sleep for a bit if we will loop again 547 | if ($PSBoundParameters['Wait']) { Start-Sleep -milliseconds $SleepTimer } 548 | 549 | #Loop again only if -wait parameter and there are more runspaces to process 550 | } while ($more -and $PSBoundParameters['Wait']) 551 | 552 | #End of runspace function 553 | } 554 | #endregion functions 555 | 556 | #region Init 557 | 558 | if ($PSCmdlet.ParameterSetName -eq 'ScriptFile') { 559 | $ScriptBlock = [scriptblock]::Create( $(Get-Content $ScriptFile | out-string) ) 560 | } 561 | elseif ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') { 562 | #Start building parameter names for the param block 563 | [string[]]$ParamsToAdd = '$_' 564 | if ( $PSBoundParameters.ContainsKey('Parameter') ) { 565 | $ParamsToAdd += '$Parameter' 566 | } 567 | 568 | $UsingVariableData = $Null 569 | 570 | # This code enables $Using support through the AST. 571 | # This is entirely from Boe Prox, and his https://github.com/proxb/PoshRSJob module; all credit to Boe! 572 | 573 | if ($PSVersionTable.PSVersion.Major -gt 2) { 574 | #Extract using references 575 | $UsingVariables = $ScriptBlock.ast.FindAll( { $args[0] -is [System.Management.Automation.Language.UsingExpressionAst] }, $True) 576 | 577 | If ($UsingVariables) { 578 | $List = New-Object 'System.Collections.Generic.List`1[System.Management.Automation.Language.VariableExpressionAst]' 579 | ForEach ($Ast in $UsingVariables) { 580 | [void]$list.Add($Ast.SubExpression) 581 | } 582 | 583 | $UsingVar = $UsingVariables | Group-Object -Property SubExpression | ForEach-Object { $_.Group | Select-Object -First 1 } 584 | 585 | #Extract the name, value, and create replacements for each 586 | $UsingVariableData = ForEach ($Var in $UsingVar) { 587 | try { 588 | $Value = Get-Variable -Name $Var.SubExpression.VariablePath.UserPath -ErrorAction Stop 589 | [pscustomobject]@{ 590 | Name = $Var.SubExpression.Extent.Text 591 | Value = $Value.Value 592 | NewName = ('$__using_{0}' -f $Var.SubExpression.VariablePath.UserPath) 593 | NewVarName = ('__using_{0}' -f $Var.SubExpression.VariablePath.UserPath) 594 | } 595 | } 596 | catch { 597 | Write-Error "$($Var.SubExpression.Extent.Text) is not a valid Using: variable!" 598 | } 599 | } 600 | $ParamsToAdd += $UsingVariableData | Select-Object -ExpandProperty NewName -Unique 601 | 602 | $NewParams = $UsingVariableData.NewName -join ', ' 603 | $Tuple = [Tuple]::Create($list, $NewParams) 604 | $bindingFlags = [Reflection.BindingFlags]"Default,NonPublic,Instance" 605 | $GetWithInputHandlingForInvokeCommandImpl = ($ScriptBlock.ast.gettype().GetMethod('GetWithInputHandlingForInvokeCommandImpl', $bindingFlags)) 606 | 607 | $StringScriptBlock = $GetWithInputHandlingForInvokeCommandImpl.Invoke($ScriptBlock.ast, @($Tuple)) 608 | 609 | $ScriptBlock = [scriptblock]::Create($StringScriptBlock) 610 | 611 | Write-Verbose $StringScriptBlock 612 | } 613 | } 614 | 615 | $ScriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock("param($($ParamsToAdd -Join ", "))`r`n" + $Scriptblock.ToString()) 616 | } 617 | else { 618 | Throw "Must provide ScriptBlock or ScriptFile"; Break 619 | } 620 | 621 | Write-Debug "`$ScriptBlock: $($ScriptBlock | Out-String)" 622 | Write-Verbose "Creating runspace pool and session states" 623 | 624 | #If specified, add variables and modules/snapins to session state 625 | $sessionstate = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() 626 | if ($ImportVariables -and $UserVariables.count -gt 0) { 627 | foreach ($Variable in $UserVariables) { 628 | $sessionstate.Variables.Add((New-Object -TypeName System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $Variable.Name, $Variable.Value, $null) ) 629 | } 630 | } 631 | if ($ImportModules) { 632 | if ($UserModules.count -gt 0) { 633 | foreach ($ModulePath in $UserModules) { 634 | $sessionstate.ImportPSModule($ModulePath) 635 | } 636 | } 637 | if ($UserSnapins.count -gt 0) { 638 | foreach ($PSSnapin in $UserSnapins) { 639 | [void]$sessionstate.ImportPSSnapIn($PSSnapin, [ref]$null) 640 | } 641 | } 642 | } 643 | if ($ImportFunctions -and $UserFunctions.count -gt 0) { 644 | foreach ($FunctionDef in $UserFunctions) { 645 | $sessionstate.Commands.Add((New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $FunctionDef.Name, $FunctionDef.ScriptBlock)) 646 | } 647 | } 648 | 649 | #Create runspace pool 650 | $runspacepool = [runspacefactory]::CreateRunspacePool(1, $Throttle, $sessionstate, $Host) 651 | $runspacepool.Open() 652 | 653 | Write-Verbose "Creating empty collection to hold runspace jobs" 654 | $Script:runspaces = New-Object System.Collections.ArrayList 655 | 656 | #If inputObject is bound get a total count and set bound to true 657 | $bound = $PSBoundParameters.keys -contains "InputObject" 658 | if (-not $bound) { 659 | [System.Collections.ArrayList]$allObjects = @() 660 | } 661 | 662 | #Set up log file if specified 663 | if ( $LogFile -and (-not (Test-Path $LogFile) -or $AppendLog -eq $false)) { 664 | New-Item -ItemType file -Path $logFile -Force | Out-Null 665 | ("" | Select-Object -Property Date, Action, Runtime, Status, Details | ConvertTo-Csv -NoTypeInformation -Delimiter ";")[0] | Out-File $LogFile 666 | } 667 | 668 | #write initial log entry 669 | $log = "" | Select-Object -Property Date, Action, Runtime, Status, Details 670 | $log.Date = Get-Date 671 | $log.Action = "Batch processing started" 672 | $log.Runtime = $null 673 | $log.Status = "Started" 674 | $log.Details = $null 675 | if ($logFile) { 676 | ($log | convertto-csv -Delimiter ";" -NoTypeInformation)[1] | Out-File $LogFile -Append 677 | } 678 | $timedOutTasks = $false 679 | #endregion INIT 680 | } 681 | process { 682 | #add piped objects to all objects or set all objects to bound input object parameter 683 | if ($bound) { 684 | $allObjects = $InputObject 685 | } 686 | else { 687 | [void]$allObjects.add( $InputObject ) 688 | } 689 | } 690 | end { 691 | #Use Try/Finally to catch Ctrl+C and clean up. 692 | try { 693 | #counts for progress 694 | $totalCount = $allObjects.count 695 | $script:completedCount = 0 696 | $startedCount = 0 697 | foreach ($object in $allObjects) { 698 | #region add scripts to runspace pool 699 | #Create the powershell instance, set verbose if needed, supply the scriptblock and parameters 700 | $powershell = [powershell]::Create() 701 | 702 | if ($VerbosePreference -eq 'Continue') { 703 | [void]$PowerShell.AddScript( { $VerbosePreference = 'Continue' }) 704 | } 705 | 706 | [void]$PowerShell.AddScript($ScriptBlock).AddArgument($object) 707 | 708 | if ($parameter) { 709 | [void]$PowerShell.AddArgument($parameter) 710 | } 711 | 712 | # $Using support from Boe Prox 713 | if ($UsingVariableData) { 714 | Foreach ($UsingVariable in $UsingVariableData) { 715 | Write-Verbose "Adding $($UsingVariable.Name) with value: $($UsingVariable.Value)" 716 | [void]$PowerShell.AddArgument($UsingVariable.Value) 717 | } 718 | } 719 | 720 | #Add the runspace into the powershell instance 721 | $powershell.RunspacePool = $runspacepool 722 | 723 | #Create a temporary collection for each runspace 724 | $temp = "" | Select-Object PowerShell, StartTime, object, Runspace 725 | $temp.PowerShell = $powershell 726 | $temp.StartTime = Get-Date 727 | $temp.object = $object 728 | 729 | #Save the handle output when calling BeginInvoke() that will be used later to end the runspace 730 | $temp.Runspace = $powershell.BeginInvoke() 731 | $startedCount++ 732 | 733 | #Add the temp tracking info to $runspaces collection 734 | Write-Verbose ( "Adding {0} to collection at {1}" -f $temp.object, $temp.starttime.tostring() ) 735 | $runspaces.Add($temp) | Out-Null 736 | 737 | #loop through existing runspaces one time 738 | Get-RunspaceData 739 | 740 | #If we have more running than max queue (used to control timeout accuracy) 741 | #Script scope resolves odd PowerShell 2 issue 742 | $firstRun = $true 743 | while ($runspaces.count -ge $Script:MaxQueue) { 744 | #give verbose output 745 | if ($firstRun) { 746 | Write-Verbose "$($runspaces.count) items running - exceeded $Script:MaxQueue limit." 747 | } 748 | $firstRun = $false 749 | 750 | #run get-runspace data and sleep for a short while 751 | Get-RunspaceData 752 | Start-Sleep -Milliseconds $sleepTimer 753 | } 754 | #endregion add scripts to runspace pool 755 | } 756 | Write-Verbose ( "Finish processing the remaining runspace jobs: {0}" -f ( @($runspaces | Where-Object { $_.Runspace -ne $Null }).Count) ) 757 | 758 | Get-RunspaceData -wait 759 | if (-not $quiet) { 760 | Write-Progress -Id $ProgressId -Activity "Running Query" -Status "Starting threads" -Completed 761 | } 762 | } 763 | finally { 764 | #Close the runspace pool, unless we specified no close on timeout and something timed out 765 | if ( ($timedOutTasks -eq $false) -or ( ($timedOutTasks -eq $true) -and ($noCloseOnTimeout -eq $false) ) ) { 766 | Write-Verbose "Closing the runspace pool" 767 | $runspacepool.close() 768 | } 769 | #collect garbage 770 | [gc]::Collect() 771 | } 772 | } 773 | } 774 | 775 | #Mount-FslDisk 776 | function Mount-FslDisk { 777 | [CmdletBinding()] 778 | 779 | Param ( 780 | [Parameter( 781 | Position = 1, 782 | ValuefromPipelineByPropertyName = $true, 783 | ValuefromPipeline = $true, 784 | Mandatory = $true 785 | )] 786 | [alias('FullName')] 787 | [System.String]$Path, 788 | 789 | [Parameter( 790 | ValuefromPipelineByPropertyName = $true, 791 | ValuefromPipeline = $true 792 | )] 793 | [Int]$TimeOut = 3, 794 | 795 | [Parameter( 796 | ValuefromPipelineByPropertyName = $true 797 | )] 798 | [Switch]$PassThru 799 | ) 800 | 801 | BEGIN { 802 | Set-StrictMode -Version Latest 803 | #Requires -RunAsAdministrator 804 | } # Begin 805 | PROCESS { 806 | 807 | try { 808 | # Mount the disk without a drive letter and get it's info, Mount-DiskImage is used to remove reliance on Hyper-V tools 809 | $mountedDisk = Mount-DiskImage -ImagePath $Path -NoDriveLetter -PassThru -ErrorAction Stop 810 | } 811 | catch { 812 | $e = $error[0] 813 | Write-Error "Failed to mount disk - `"$e`"" 814 | return 815 | } 816 | 817 | 818 | $diskNumber = $false 819 | $timespan = (Get-Date).AddSeconds($TimeOut) 820 | while ($diskNumber -eq $false -and $timespan -gt (Get-Date)) { 821 | Start-Sleep 0.1 822 | try { 823 | $mountedDisk = Get-DiskImage -ImagePath $Path 824 | if ($mountedDisk.Number) { 825 | $diskNumber = $true 826 | } 827 | } 828 | catch { 829 | $diskNumber = $false 830 | } 831 | 832 | } 833 | 834 | if ($diskNumber -eq $false) { 835 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 836 | catch { 837 | Write-Error 'Could not dismount Disk Due to no Disknumber' 838 | } 839 | Write-Error 'Cannot get mount information' 840 | return 841 | } 842 | 843 | $partitionType = $false 844 | $timespan = (Get-Date).AddSeconds($TimeOut) 845 | while ($partitionType -eq $false -and $timespan -gt (Get-Date)) { 846 | 847 | try { 848 | $allPartition = Get-Partition -DiskNumber $mountedDisk.Number -ErrorAction Stop 849 | 850 | if ($allPartition.Type -contains 'Basic') { 851 | $partitionType = $true 852 | $partition = $allPartition | Where-Object -Property 'Type' -EQ -Value 'Basic' 853 | } 854 | } 855 | catch { 856 | if (($allPartition | Measure-Object).Count -gt 0) { 857 | $partition = $allPartition | Select-Object -Last 1 858 | $partitionType = $true 859 | } 860 | else{ 861 | 862 | $partitionType = $false 863 | } 864 | 865 | } 866 | Start-Sleep 0.1 867 | } 868 | 869 | if ($partitionType -eq $false) { 870 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 871 | catch { 872 | Write-Error 'Could not dismount disk with no partition' 873 | } 874 | Write-Error 'Cannot get partition information' 875 | return 876 | } 877 | 878 | # Assign vhd to a random path in temp folder so we don't have to worry about free drive letters which can be horrible 879 | # New-Guid not used here for PoSh 3 compatibility 880 | $tempGUID = [guid]::NewGuid().ToString() 881 | $mountPath = Join-Path $Env:Temp ('FSLogixMnt-' + $tempGUID) 882 | 883 | try { 884 | # Create directory which we will mount too 885 | New-Item -Path $mountPath -ItemType Directory -ErrorAction Stop | Out-Null 886 | } 887 | catch { 888 | $e = $error[0] 889 | # Cleanup 890 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 891 | catch { 892 | Write-Error "Could not dismount disk when no folder could be created - `"$e`"" 893 | } 894 | Write-Error "Failed to create mounting directory - `"$e`"" 895 | return 896 | } 897 | 898 | try { 899 | $addPartitionAccessPathParams = @{ 900 | DiskNumber = $mountedDisk.Number 901 | PartitionNumber = $partition.PartitionNumber 902 | AccessPath = $mountPath 903 | ErrorAction = 'Stop' 904 | } 905 | 906 | Add-PartitionAccessPath @addPartitionAccessPathParams 907 | } 908 | catch { 909 | $e = $error[0] 910 | # Cleanup 911 | Remove-Item -Path $mountPath -Force -Recurse -ErrorAction SilentlyContinue 912 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 913 | catch { 914 | Write-Error "Could not dismount disk when no junction point could be created - `"$e`"" 915 | } 916 | Write-Error "Failed to create junction point to - `"$e`"" 917 | return 918 | } 919 | 920 | if ($PassThru) { 921 | # Create output required for piping to Dismount-FslDisk 922 | $output = [PSCustomObject]@{ 923 | Path = $mountPath 924 | DiskNumber = $mountedDisk.Number 925 | ImagePath = $mountedDisk.ImagePath 926 | PartitionNumber = $partition.PartitionNumber 927 | } 928 | Write-Output $output 929 | } 930 | Write-Verbose "Mounted $Path to $mountPath" 931 | } #Process 932 | END { 933 | 934 | } #End 935 | } #function Mount-FslDisk 936 | 937 | #Dismount-FslDisk 938 | function Dismount-FslDisk { 939 | [CmdletBinding()] 940 | 941 | Param ( 942 | [Parameter( 943 | Position = 1, 944 | ValuefromPipelineByPropertyName = $true, 945 | ValuefromPipeline = $true, 946 | Mandatory = $true 947 | )] 948 | [String]$Path, 949 | 950 | [Parameter( 951 | ValuefromPipelineByPropertyName = $true, 952 | Mandatory = $true 953 | )] 954 | [String]$ImagePath, 955 | 956 | [Parameter( 957 | ValuefromPipelineByPropertyName = $true 958 | )] 959 | [Switch]$PassThru, 960 | 961 | [Parameter( 962 | ValuefromPipelineByPropertyName = $true 963 | )] 964 | [Int]$Timeout = 120 965 | ) 966 | 967 | BEGIN { 968 | Set-StrictMode -Version Latest 969 | #Requires -RunAsAdministrator 970 | } # Begin 971 | PROCESS { 972 | 973 | $mountRemoved = $false 974 | $directoryRemoved = $false 975 | 976 | # Reverse the tasks from Mount-FslDisk 977 | 978 | $timeStampDirectory = (Get-Date).AddSeconds(20) 979 | 980 | while ((Get-Date) -lt $timeStampDirectory -and $directoryRemoved -ne $true) { 981 | try { 982 | Remove-Item -Path $Path -Force -Recurse -ErrorAction Stop | Out-Null 983 | $directoryRemoved = $true 984 | } 985 | catch { 986 | $directoryRemoved = $false 987 | } 988 | } 989 | if (Test-Path $Path) { 990 | Write-Warning "Failed to delete temp mount directory $Path" 991 | } 992 | 993 | 994 | $timeStampDismount = (Get-Date).AddSeconds($Timeout) 995 | while ((Get-Date) -lt $timeStampDismount -and $mountRemoved -ne $true) { 996 | try { 997 | Dismount-DiskImage -ImagePath $ImagePath -ErrorAction Stop | Out-Null 998 | #double/triple check disk is dismounted due to disk manager service being a pain. 999 | 1000 | try { 1001 | $image = Get-DiskImage -ImagePath $ImagePath -ErrorAction Stop 1002 | 1003 | switch ($image.Attached) { 1004 | $null { $mountRemoved = $false ; Start-Sleep 0.1; break } 1005 | $true { $mountRemoved = $false ; break} 1006 | $false { $mountRemoved = $true ; break } 1007 | Default { $mountRemoved = $false } 1008 | } 1009 | } 1010 | catch { 1011 | $mountRemoved = $false 1012 | } 1013 | } 1014 | catch { 1015 | $mountRemoved = $false 1016 | } 1017 | } 1018 | if ($mountRemoved -ne $true) { 1019 | Write-Error "Failed to dismount disk $ImagePath" 1020 | } 1021 | 1022 | If ($PassThru) { 1023 | $output = [PSCustomObject]@{ 1024 | MountRemoved = $mountRemoved 1025 | DirectoryRemoved = $directoryRemoved 1026 | } 1027 | Write-Output $output 1028 | } 1029 | if ($directoryRemoved -and $mountRemoved) { 1030 | Write-Verbose "Dismounted $ImagePath" 1031 | } 1032 | 1033 | } #Process 1034 | END { } #End 1035 | } #function Dismount-FslDisk 1036 | 1037 | #Optimize-OneDisk 1038 | function Optimize-OneDisk { 1039 | [CmdletBinding()] 1040 | 1041 | Param ( 1042 | [Parameter( 1043 | ValuefromPipelineByPropertyName = $true, 1044 | ValuefromPipeline = $true, 1045 | Mandatory = $true 1046 | )] 1047 | [System.IO.FileInfo]$Disk, 1048 | 1049 | [Parameter( 1050 | ValuefromPipelineByPropertyName = $true 1051 | )] 1052 | [Int]$DeleteOlderThanDays, 1053 | 1054 | [Parameter( 1055 | ValuefromPipelineByPropertyName = $true 1056 | )] 1057 | [Int]$IgnoreLessThanGB, 1058 | 1059 | [Parameter( 1060 | ValuefromPipelineByPropertyName = $true 1061 | )] 1062 | [double]$RatioFreeSpace = 0.05, 1063 | 1064 | [Parameter( 1065 | ValuefromPipelineByPropertyName = $true 1066 | )] 1067 | [int]$MountTimeout = 30, 1068 | 1069 | [Parameter( 1070 | ValuefromPipelineByPropertyName = $true 1071 | )] 1072 | [string]$LogFilePath = "$env:TEMP\FslShrinkDisk $(Get-Date -Format yyyy-MM-dd` HH-mm-ss).csv", 1073 | 1074 | [Parameter( 1075 | ValuefromPipelineByPropertyName = $true 1076 | )] 1077 | [switch]$Passthru 1078 | 1079 | ) 1080 | 1081 | BEGIN { 1082 | #Requires -RunAsAdministrator 1083 | Set-StrictMode -Version Latest 1084 | $hyperv = $false 1085 | } # Begin 1086 | PROCESS { 1087 | #In case there are disks left mounted let's try to clean up. 1088 | Dismount-DiskImage -ImagePath $Disk.FullName -ErrorAction SilentlyContinue 1089 | 1090 | #Get start time for logfile 1091 | $startTime = Get-Date 1092 | if ( $IgnoreLessThanGB ) { 1093 | $IgnoreLessThanBytes = $IgnoreLessThanGB * 1024 * 1024 * 1024 1094 | } 1095 | 1096 | #Grab size of disk being processed 1097 | $originalSize = $Disk.Length 1098 | 1099 | #Set default parameter values for the Write-VhdOutput command to prevent repeating code below, these can be overridden as I need to. Calclations to be done in the output function, raw data goes in. 1100 | $PSDefaultParameterValues = @{ 1101 | "Write-VhdOutput:Path" = $LogFilePath 1102 | "Write-VhdOutput:StartTime" = $startTime 1103 | "Write-VhdOutput:Name" = $Disk.Name 1104 | "Write-VhdOutput:DiskState" = $null 1105 | "Write-VhdOutput:OriginalSize" = $originalSize 1106 | "Write-VhdOutput:FinalSize" = $originalSize 1107 | "Write-VhdOutput:FullName" = $Disk.FullName 1108 | "Write-VhdOutput:Passthru" = $Passthru 1109 | } 1110 | 1111 | #Check it is a disk 1112 | if ($Disk.Extension -ne '.vhd' -and $Disk.Extension -ne '.vhdx' ) { 1113 | Write-VhdOutput -DiskState 'File Is Not a Virtual Hard Disk format with extension vhd or vhdx' -EndTime (Get-Date) 1114 | return 1115 | } 1116 | 1117 | #If it's older than x days delete disk 1118 | If ( $DeleteOlderThanDays ) { 1119 | #Last Access time isn't always reliable if diff disks are used so lets be safe and use the most recent of access and write 1120 | $mostRecent = $Disk.LastAccessTime, $Disk.LastWriteTime | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum 1121 | if ($mostRecent -lt (Get-Date).AddDays(-$DeleteOlderThanDays) ) { 1122 | try { 1123 | Remove-Item $Disk.FullName -ErrorAction Stop -Force 1124 | Write-VhdOutput -DiskState "Deleted" -FinalSize 0 -EndTime (Get-Date) 1125 | } 1126 | catch { 1127 | Write-VhdOutput -DiskState 'Disk Deletion Failed' -EndTime (Get-Date) 1128 | } 1129 | return 1130 | } 1131 | } 1132 | 1133 | #As disks take time to process, if you have a lot of disks, it may not be worth shrinking the small onesBytes 1134 | if ( $IgnoreLessThanGB -and $originalSize -lt $IgnoreLessThanBytes ) { 1135 | Write-VhdOutput -DiskState 'Ignored' -EndTime (Get-Date) 1136 | return 1137 | } 1138 | 1139 | #Initial disk Mount 1140 | try { 1141 | $mount = Mount-FslDisk -Path $Disk.FullName -TimeOut 30 -PassThru -ErrorAction Stop 1142 | } 1143 | catch { 1144 | $err = $error[0] 1145 | Write-VhdOutput -DiskState $err -EndTime (Get-Date) 1146 | return 1147 | } 1148 | 1149 | #Grabbing partition info can fail when the client is under heavy load so....... 1150 | $timespan = (Get-Date).AddSeconds(120) 1151 | $partInfo = $null 1152 | while (($partInfo | Measure-Object).Count -lt 1 -and $timespan -gt (Get-Date)) { 1153 | try { 1154 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber -ErrorAction Stop | Where-Object -Property 'Type' -EQ -Value 'Basic' -ErrorAction Stop 1155 | } 1156 | catch { 1157 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber -ErrorAction SilentlyContinue | Select-Object -Last 1 1158 | } 1159 | Start-Sleep 0.1 1160 | } 1161 | 1162 | if (($partInfo | Measure-Object).Count -eq 0) { 1163 | $mount | DisMount-FslDisk 1164 | Write-VhdOutput -DiskState 'No Partition Information - The Windows Disk SubSystem did not respond in a timely fashion try increasing number of cores or decreasing threads by using the ThrottleLimit parameter' -EndTime (Get-Date) 1165 | return 1166 | } 1167 | 1168 | $timespan = (Get-Date).AddSeconds(120) 1169 | $defrag = $false 1170 | while ($defrag -eq $false -and $timespan -gt (Get-Date)) { 1171 | try { 1172 | Get-Volume -Partition $partInfo -ErrorAction Stop | Optimize-Volume -ErrorAction Stop 1173 | $defrag = $true 1174 | } 1175 | catch { 1176 | try { 1177 | Get-Volume -ErrorAction Stop | Where-Object { 1178 | $_.UniqueId -like "*$($partInfo.Guid)*" 1179 | -or $_.Path -Like "*$($partInfo.Guid)*" 1180 | -or $_.ObjectId -Like "*$($partInfo.Guid)*" } | Optimize-Volume -ErrorAction Stop 1181 | $defrag = $true 1182 | } 1183 | catch { 1184 | $defrag = $false 1185 | Start-Sleep 0.1 1186 | } 1187 | $defrag = $false 1188 | } 1189 | } 1190 | 1191 | #Grab partition information so we know what size to shrink the partition to and what to re-enlarge it to. This helps optimise-vhd work at it's best 1192 | $partSize = $false 1193 | $timespan = (Get-Date).AddSeconds(30) 1194 | while ($partSize -eq $false -and $timespan -gt (Get-Date)) { 1195 | try { 1196 | $partitionsize = $partInfo | Get-PartitionSupportedSize -ErrorAction Stop 1197 | $sizeMax = $partitionsize.SizeMax 1198 | $partSize = $true 1199 | } 1200 | catch { 1201 | try { 1202 | $partitionsize = Get-PartitionSupportedSize -DiskNumber $mount.DiskNumber -PartitionNumber $mount.PartitionNumber -ErrorAction Stop 1203 | $sizeMax = $partitionsize.SizeMax 1204 | $partSize = $true 1205 | } 1206 | catch { 1207 | $partSize = $false 1208 | Start-Sleep 0.1 1209 | } 1210 | $partSize = $false 1211 | 1212 | } 1213 | } 1214 | 1215 | if ($partSize -eq $false) { 1216 | #$partInfo | Export-Clixml -Path "$env:TEMP\ForJim-$($Disk.Name).xml" 1217 | Write-VhdOutput -DiskState 'No Partition Supported Size Info - The Windows Disk SubSystem did not respond in a timely fashion try increasing number of cores or decreasing threads by using the ThrottleLimit parameter' -EndTime (Get-Date) 1218 | $mount | DisMount-FslDisk 1219 | return 1220 | } 1221 | 1222 | 1223 | #If you can't shrink the partition much, you can't reclaim a lot of space, so skipping if it's not worth it. Otherwise shink partition and dismount disk 1224 | 1225 | if ( $partitionsize.SizeMin -gt $disk.Length ) { 1226 | Write-VhdOutput -DiskState "SkippedAlreadyMinimum" -EndTime (Get-Date) 1227 | $mount | DisMount-FslDisk 1228 | return 1229 | } 1230 | 1231 | 1232 | if (($partitionsize.SizeMin / $disk.Length) -gt (1 - $RatioFreeSpace) ) { 1233 | Write-VhdOutput -DiskState "LessThan$(100*$RatioFreeSpace)%FreeInsideDisk" -EndTime (Get-Date) 1234 | $mount | DisMount-FslDisk 1235 | return 1236 | } 1237 | 1238 | #If I decide to add Hyper-V module support, I'll need this code later 1239 | if ($hyperv -eq $true) { 1240 | 1241 | #In some cases you can't do the partition shrink to the min so increasing by 100 MB each time till it shrinks 1242 | $i = 0 1243 | $resize = $false 1244 | $targetSize = $partitionsize.SizeMin 1245 | $sizeBytesIncrement = 100 * 1024 * 1024 1246 | 1247 | while ($i -le 5 -and $resize -eq $false) { 1248 | try { 1249 | Resize-Partition -InputObject $partInfo -Size $targetSize -ErrorAction Stop 1250 | $resize = $true 1251 | } 1252 | catch { 1253 | $resize = $false 1254 | $targetSize = $targetSize + $sizeBytesIncrement 1255 | $i++ 1256 | } 1257 | finally { 1258 | Start-Sleep 1 1259 | } 1260 | } 1261 | 1262 | #Whatever happens now we need to dismount 1263 | 1264 | if ($resize -eq $false) { 1265 | Write-VhdOutput -DiskState "PartitionShrinkFailed" -EndTime (Get-Date) 1266 | $mount | DisMount-FslDisk 1267 | return 1268 | } 1269 | } 1270 | 1271 | $mount | DisMount-FslDisk 1272 | 1273 | #Change the disk size and grab the new size 1274 | 1275 | $retries = 0 1276 | $success = $false 1277 | #Diskpart is a little erratic and can fail occasionally, so stuck it in a loop. 1278 | while ($retries -lt 30 -and $success -ne $true) { 1279 | 1280 | $tempFileName = "$env:TEMP\FslDiskPart$($Disk.Name).txt" 1281 | 1282 | #Let's put diskpart into a function just so I can use Pester to Mock it 1283 | function invoke-diskpart ($Path) { 1284 | #diskpart needs you to write a txt file so you can automate it, because apparently it's 1989. 1285 | #A better way would be to use optimize-vhd from the Hyper-V module, 1286 | # but that only comes along with installing the actual role, which needs CPU virtualisation extensions present, 1287 | # which is a PITA in cloud and virtualised environments where you can't do Hyper-V. 1288 | #MaybeDo, use hyper-V module if it's there if not use diskpart? two code paths to do the same thing probably not smart though, it would be a way to solve localisation issues. 1289 | Set-Content -Path $Path -Value "SELECT VDISK FILE=`'$($Disk.FullName)`'" 1290 | Add-Content -Path $Path -Value 'attach vdisk readonly' 1291 | Add-Content -Path $Path -Value 'COMPACT VDISK' 1292 | Add-Content -Path $Path -Value 'detach vdisk' 1293 | $result = DISKPART /s $Path 1294 | Write-Output $result 1295 | } 1296 | 1297 | $diskPartResult = invoke-diskpart -Path $tempFileName 1298 | 1299 | #diskpart doesn't return an object (1989 remember) so we have to parse the text output. 1300 | if ($diskPartResult -contains 'DiskPart successfully compacted the virtual disk file.') { 1301 | $finalSize = Get-ChildItem $Disk.FullName | Select-Object -ExpandProperty Length 1302 | $success = $true 1303 | Remove-Item $tempFileName 1304 | } 1305 | else { 1306 | Set-Content -Path "$env:TEMP\FslDiskPartError$($Disk.Name)-$retries.log" -Value $diskPartResult 1307 | $retries++ 1308 | #if DiskPart fails, try, try again. 1309 | } 1310 | Start-Sleep 1 1311 | } 1312 | 1313 | If ($success -ne $true) { 1314 | Write-VhdOutput -DiskState "DiskShrinkFailed" -EndTime (Get-Date) 1315 | Remove-Item $tempFileName 1316 | return 1317 | } 1318 | 1319 | #If I decide to add Hyper-V module support, I'll need this code later 1320 | if ($hyperv -eq $true) { 1321 | #Now we need to reinflate the partition to its previous size 1322 | try { 1323 | $mount = Mount-FslDisk -Path $Disk.FullName -PassThru 1324 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber | Where-Object -Property 'Type' -EQ -Value 'Basic' 1325 | Resize-Partition -InputObject $partInfo -Size $sizeMax -ErrorAction Stop 1326 | $paramWriteVhdOutput = @{ 1327 | DiskState = "Success" 1328 | FinalSize = $finalSize 1329 | EndTime = Get-Date 1330 | } 1331 | Write-VhdOutput @paramWriteVhdOutput 1332 | } 1333 | catch { 1334 | Write-VhdOutput -DiskState "PartitionSizeRestoreFailed" -EndTime (Get-Date) 1335 | return 1336 | } 1337 | finally { 1338 | $mount | DisMount-FslDisk 1339 | } 1340 | } 1341 | 1342 | 1343 | $paramWriteVhdOutput = @{ 1344 | DiskState = "Success" 1345 | FinalSize = $finalSize 1346 | EndTime = Get-Date 1347 | } 1348 | Write-VhdOutput @paramWriteVhdOutput 1349 | } #Process 1350 | END { } #End 1351 | } #function Optimize-OneDisk 1352 | 1353 | #Write Output to file and optionally to pipeline 1354 | function Write-VhdOutput { 1355 | [CmdletBinding()] 1356 | 1357 | Param ( 1358 | [Parameter( 1359 | Mandatory = $true 1360 | )] 1361 | [System.String]$Path, 1362 | 1363 | [Parameter( 1364 | Mandatory = $true 1365 | )] 1366 | [System.String]$Name, 1367 | 1368 | [Parameter( 1369 | Mandatory = $true 1370 | )] 1371 | [System.String]$DiskState, 1372 | 1373 | [Parameter( 1374 | Mandatory = $true 1375 | )] 1376 | [System.String]$OriginalSize, 1377 | 1378 | [Parameter( 1379 | Mandatory = $true 1380 | )] 1381 | [System.String]$FinalSize, 1382 | 1383 | [Parameter( 1384 | Mandatory = $true 1385 | )] 1386 | [System.String]$FullName, 1387 | 1388 | [Parameter( 1389 | Mandatory = $true 1390 | )] 1391 | [datetime]$StartTime, 1392 | 1393 | [Parameter( 1394 | Mandatory = $true 1395 | )] 1396 | [datetime]$EndTime, 1397 | 1398 | [Parameter( 1399 | Mandatory = $true 1400 | )] 1401 | [Switch]$Passthru 1402 | ) 1403 | 1404 | BEGIN { 1405 | Set-StrictMode -Version Latest 1406 | } # Begin 1407 | PROCESS { 1408 | 1409 | #unit conversion and calculation should happen in output function 1410 | $output = [PSCustomObject]@{ 1411 | Name = $Name 1412 | StartTime = $StartTime.ToLongTimeString() 1413 | EndTime = $EndTime.ToLongTimeString() 1414 | 'ElapsedTime(s)' = [math]::Round(($EndTime - $StartTime).TotalSeconds, 1) 1415 | DiskState = $DiskState 1416 | OriginalSizeGB = [math]::Round( $OriginalSize / 1GB, 2 ) 1417 | FinalSizeGB = [math]::Round( $FinalSize / 1GB, 2 ) 1418 | SpaceSavedGB = [math]::Round( ($OriginalSize - $FinalSize) / 1GB, 2 ) 1419 | FullName = $FullName 1420 | } 1421 | 1422 | if ($Passthru) { 1423 | Write-Output $output 1424 | } 1425 | $success = $False 1426 | $retries = 0 1427 | while ($retries -lt 10 -and $success -ne $true) { 1428 | try { 1429 | $output | Export-Csv -Path $Path -NoClobber -Append -ErrorAction Stop -NoTypeInformation 1430 | $success = $true 1431 | } 1432 | catch { 1433 | $retries++ 1434 | } 1435 | Start-Sleep 1 1436 | } 1437 | 1438 | 1439 | } #Process 1440 | END { } #End 1441 | } #function Write-VhdOutput.ps1 1442 | 1443 | $servicesToTest = 'defragsvc', 'vds' 1444 | try{ 1445 | $servicesToTest | Test-FslDependencies -ErrorAction Stop 1446 | } 1447 | catch{ 1448 | $err = $error[0] 1449 | Write-Error $err 1450 | return 1451 | } 1452 | $numberOfCores = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors 1453 | 1454 | If (($ThrottleLimit / 2) -gt $numberOfCores) { 1455 | 1456 | $ThrottleLimit = $numberOfCores * 2 1457 | Write-Warning "Number of threads set to double the number of cores - $ThrottleLimit" 1458 | } 1459 | 1460 | } # Begin 1461 | PROCESS { 1462 | 1463 | #Check that the path is valid 1464 | if (-not (Test-Path $Path)) { 1465 | Write-Error "$Path not found" 1466 | return 1467 | } 1468 | 1469 | #Get a list of Virtual Hard Disk files depending on the recurse parameter 1470 | if ($Recurse) { 1471 | $diskList = Get-ChildItem -File -Filter *.vhd? -Path $Path -Recurse 1472 | } 1473 | else { 1474 | $diskList = Get-ChildItem -File -Filter *.vhd? -Path $Path 1475 | } 1476 | 1477 | $diskList = $diskList | Where-Object { $_.Name -ne "Merge.vhdx" -and $_.Name -ne "RW.vhdx" } 1478 | 1479 | #If we can't find and files with the extension vhd or vhdx quit 1480 | if ( ($diskList | Measure-Object).count -eq 0 ) { 1481 | Write-Warning "No files to process in $Path" 1482 | return 1483 | } 1484 | 1485 | $scriptblockForEachObject = { 1486 | 1487 | #ForEach-Object -Parallel doesn't seem to want to import functions, so defining them twice, good job this is automated. 1488 | 1489 | #Mount-FslDisk 1490 | function Mount-FslDisk { 1491 | [CmdletBinding()] 1492 | 1493 | Param ( 1494 | [Parameter( 1495 | Position = 1, 1496 | ValuefromPipelineByPropertyName = $true, 1497 | ValuefromPipeline = $true, 1498 | Mandatory = $true 1499 | )] 1500 | [alias('FullName')] 1501 | [System.String]$Path, 1502 | 1503 | [Parameter( 1504 | ValuefromPipelineByPropertyName = $true, 1505 | ValuefromPipeline = $true 1506 | )] 1507 | [Int]$TimeOut = 3, 1508 | 1509 | [Parameter( 1510 | ValuefromPipelineByPropertyName = $true 1511 | )] 1512 | [Switch]$PassThru 1513 | ) 1514 | 1515 | BEGIN { 1516 | Set-StrictMode -Version Latest 1517 | #Requires -RunAsAdministrator 1518 | } # Begin 1519 | PROCESS { 1520 | 1521 | try { 1522 | # Mount the disk without a drive letter and get it's info, Mount-DiskImage is used to remove reliance on Hyper-V tools 1523 | $mountedDisk = Mount-DiskImage -ImagePath $Path -NoDriveLetter -PassThru -ErrorAction Stop 1524 | } 1525 | catch { 1526 | $e = $error[0] 1527 | Write-Error "Failed to mount disk - `"$e`"" 1528 | return 1529 | } 1530 | 1531 | 1532 | $diskNumber = $false 1533 | $timespan = (Get-Date).AddSeconds($TimeOut) 1534 | while ($diskNumber -eq $false -and $timespan -gt (Get-Date)) { 1535 | Start-Sleep 0.1 1536 | try { 1537 | $mountedDisk = Get-DiskImage -ImagePath $Path 1538 | if ($mountedDisk.Number) { 1539 | $diskNumber = $true 1540 | } 1541 | } 1542 | catch { 1543 | $diskNumber = $false 1544 | } 1545 | 1546 | } 1547 | 1548 | if ($diskNumber -eq $false) { 1549 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 1550 | catch { 1551 | Write-Error 'Could not dismount Disk Due to no Disknumber' 1552 | } 1553 | Write-Error 'Cannot get mount information' 1554 | return 1555 | } 1556 | 1557 | $partitionType = $false 1558 | $timespan = (Get-Date).AddSeconds($TimeOut) 1559 | while ($partitionType -eq $false -and $timespan -gt (Get-Date)) { 1560 | 1561 | try { 1562 | $allPartition = Get-Partition -DiskNumber $mountedDisk.Number -ErrorAction Stop 1563 | 1564 | if ($allPartition.Type -contains 'Basic') { 1565 | $partitionType = $true 1566 | $partition = $allPartition | Where-Object -Property 'Type' -EQ -Value 'Basic' 1567 | } 1568 | } 1569 | catch { 1570 | if (($allPartition | Measure-Object).Count -gt 0) { 1571 | $partition = $allPartition | Select-Object -Last 1 1572 | $partitionType = $true 1573 | } 1574 | else{ 1575 | 1576 | $partitionType = $false 1577 | } 1578 | 1579 | } 1580 | Start-Sleep 0.1 1581 | } 1582 | 1583 | if ($partitionType -eq $false) { 1584 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 1585 | catch { 1586 | Write-Error 'Could not dismount disk with no partition' 1587 | } 1588 | Write-Error 'Cannot get partition information' 1589 | return 1590 | } 1591 | 1592 | # Assign vhd to a random path in temp folder so we don't have to worry about free drive letters which can be horrible 1593 | # New-Guid not used here for PoSh 3 compatibility 1594 | $tempGUID = [guid]::NewGuid().ToString() 1595 | $mountPath = Join-Path $Env:Temp ('FSLogixMnt-' + $tempGUID) 1596 | 1597 | try { 1598 | # Create directory which we will mount too 1599 | New-Item -Path $mountPath -ItemType Directory -ErrorAction Stop | Out-Null 1600 | } 1601 | catch { 1602 | $e = $error[0] 1603 | # Cleanup 1604 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 1605 | catch { 1606 | Write-Error "Could not dismount disk when no folder could be created - `"$e`"" 1607 | } 1608 | Write-Error "Failed to create mounting directory - `"$e`"" 1609 | return 1610 | } 1611 | 1612 | try { 1613 | $addPartitionAccessPathParams = @{ 1614 | DiskNumber = $mountedDisk.Number 1615 | PartitionNumber = $partition.PartitionNumber 1616 | AccessPath = $mountPath 1617 | ErrorAction = 'Stop' 1618 | } 1619 | 1620 | Add-PartitionAccessPath @addPartitionAccessPathParams 1621 | } 1622 | catch { 1623 | $e = $error[0] 1624 | # Cleanup 1625 | Remove-Item -Path $mountPath -Force -Recurse -ErrorAction SilentlyContinue 1626 | try { $mountedDisk | Dismount-DiskImage -ErrorAction SilentlyContinue } 1627 | catch { 1628 | Write-Error "Could not dismount disk when no junction point could be created - `"$e`"" 1629 | } 1630 | Write-Error "Failed to create junction point to - `"$e`"" 1631 | return 1632 | } 1633 | 1634 | if ($PassThru) { 1635 | # Create output required for piping to Dismount-FslDisk 1636 | $output = [PSCustomObject]@{ 1637 | Path = $mountPath 1638 | DiskNumber = $mountedDisk.Number 1639 | ImagePath = $mountedDisk.ImagePath 1640 | PartitionNumber = $partition.PartitionNumber 1641 | } 1642 | Write-Output $output 1643 | } 1644 | Write-Verbose "Mounted $Path to $mountPath" 1645 | } #Process 1646 | END { 1647 | 1648 | } #End 1649 | } #function Mount-FslDisk 1650 | #Dismount-FslDisk 1651 | function Dismount-FslDisk { 1652 | [CmdletBinding()] 1653 | 1654 | Param ( 1655 | [Parameter( 1656 | Position = 1, 1657 | ValuefromPipelineByPropertyName = $true, 1658 | ValuefromPipeline = $true, 1659 | Mandatory = $true 1660 | )] 1661 | [String]$Path, 1662 | 1663 | [Parameter( 1664 | ValuefromPipelineByPropertyName = $true, 1665 | Mandatory = $true 1666 | )] 1667 | [String]$ImagePath, 1668 | 1669 | [Parameter( 1670 | ValuefromPipelineByPropertyName = $true 1671 | )] 1672 | [Switch]$PassThru, 1673 | 1674 | [Parameter( 1675 | ValuefromPipelineByPropertyName = $true 1676 | )] 1677 | [Int]$Timeout = 120 1678 | ) 1679 | 1680 | BEGIN { 1681 | Set-StrictMode -Version Latest 1682 | #Requires -RunAsAdministrator 1683 | } # Begin 1684 | PROCESS { 1685 | 1686 | $mountRemoved = $false 1687 | $directoryRemoved = $false 1688 | 1689 | # Reverse the tasks from Mount-FslDisk 1690 | 1691 | $timeStampDirectory = (Get-Date).AddSeconds(20) 1692 | 1693 | while ((Get-Date) -lt $timeStampDirectory -and $directoryRemoved -ne $true) { 1694 | try { 1695 | Remove-Item -Path $Path -Force -Recurse -ErrorAction Stop | Out-Null 1696 | $directoryRemoved = $true 1697 | } 1698 | catch { 1699 | $directoryRemoved = $false 1700 | } 1701 | } 1702 | if (Test-Path $Path) { 1703 | Write-Warning "Failed to delete temp mount directory $Path" 1704 | } 1705 | 1706 | 1707 | $timeStampDismount = (Get-Date).AddSeconds($Timeout) 1708 | while ((Get-Date) -lt $timeStampDismount -and $mountRemoved -ne $true) { 1709 | try { 1710 | Dismount-DiskImage -ImagePath $ImagePath -ErrorAction Stop | Out-Null 1711 | #double/triple check disk is dismounted due to disk manager service being a pain. 1712 | 1713 | try { 1714 | $image = Get-DiskImage -ImagePath $ImagePath -ErrorAction Stop 1715 | 1716 | switch ($image.Attached) { 1717 | $null { $mountRemoved = $false ; Start-Sleep 0.1; break } 1718 | $true { $mountRemoved = $false ; break} 1719 | $false { $mountRemoved = $true ; break } 1720 | Default { $mountRemoved = $false } 1721 | } 1722 | } 1723 | catch { 1724 | $mountRemoved = $false 1725 | } 1726 | } 1727 | catch { 1728 | $mountRemoved = $false 1729 | } 1730 | } 1731 | if ($mountRemoved -ne $true) { 1732 | Write-Error "Failed to dismount disk $ImagePath" 1733 | } 1734 | 1735 | If ($PassThru) { 1736 | $output = [PSCustomObject]@{ 1737 | MountRemoved = $mountRemoved 1738 | DirectoryRemoved = $directoryRemoved 1739 | } 1740 | Write-Output $output 1741 | } 1742 | if ($directoryRemoved -and $mountRemoved) { 1743 | Write-Verbose "Dismounted $ImagePath" 1744 | } 1745 | 1746 | } #Process 1747 | END { } #End 1748 | } #function Dismount-FslDisk 1749 | #Optimize-OneDisk 1750 | function Optimize-OneDisk { 1751 | [CmdletBinding()] 1752 | 1753 | Param ( 1754 | [Parameter( 1755 | ValuefromPipelineByPropertyName = $true, 1756 | ValuefromPipeline = $true, 1757 | Mandatory = $true 1758 | )] 1759 | [System.IO.FileInfo]$Disk, 1760 | 1761 | [Parameter( 1762 | ValuefromPipelineByPropertyName = $true 1763 | )] 1764 | [Int]$DeleteOlderThanDays, 1765 | 1766 | [Parameter( 1767 | ValuefromPipelineByPropertyName = $true 1768 | )] 1769 | [Int]$IgnoreLessThanGB, 1770 | 1771 | [Parameter( 1772 | ValuefromPipelineByPropertyName = $true 1773 | )] 1774 | [double]$RatioFreeSpace = 0.05, 1775 | 1776 | [Parameter( 1777 | ValuefromPipelineByPropertyName = $true 1778 | )] 1779 | [int]$MountTimeout = 30, 1780 | 1781 | [Parameter( 1782 | ValuefromPipelineByPropertyName = $true 1783 | )] 1784 | [string]$LogFilePath = "$env:TEMP\FslShrinkDisk $(Get-Date -Format yyyy-MM-dd` HH-mm-ss).csv", 1785 | 1786 | [Parameter( 1787 | ValuefromPipelineByPropertyName = $true 1788 | )] 1789 | [switch]$Passthru 1790 | 1791 | ) 1792 | 1793 | BEGIN { 1794 | #Requires -RunAsAdministrator 1795 | Set-StrictMode -Version Latest 1796 | $hyperv = $false 1797 | } # Begin 1798 | PROCESS { 1799 | #In case there are disks left mounted let's try to clean up. 1800 | Dismount-DiskImage -ImagePath $Disk.FullName -ErrorAction SilentlyContinue 1801 | 1802 | #Get start time for logfile 1803 | $startTime = Get-Date 1804 | if ( $IgnoreLessThanGB ) { 1805 | $IgnoreLessThanBytes = $IgnoreLessThanGB * 1024 * 1024 * 1024 1806 | } 1807 | 1808 | #Grab size of disk being processed 1809 | $originalSize = $Disk.Length 1810 | 1811 | #Set default parameter values for the Write-VhdOutput command to prevent repeating code below, these can be overridden as I need to. Calclations to be done in the output function, raw data goes in. 1812 | $PSDefaultParameterValues = @{ 1813 | "Write-VhdOutput:Path" = $LogFilePath 1814 | "Write-VhdOutput:StartTime" = $startTime 1815 | "Write-VhdOutput:Name" = $Disk.Name 1816 | "Write-VhdOutput:DiskState" = $null 1817 | "Write-VhdOutput:OriginalSize" = $originalSize 1818 | "Write-VhdOutput:FinalSize" = $originalSize 1819 | "Write-VhdOutput:FullName" = $Disk.FullName 1820 | "Write-VhdOutput:Passthru" = $Passthru 1821 | } 1822 | 1823 | #Check it is a disk 1824 | if ($Disk.Extension -ne '.vhd' -and $Disk.Extension -ne '.vhdx' ) { 1825 | Write-VhdOutput -DiskState 'File Is Not a Virtual Hard Disk format with extension vhd or vhdx' -EndTime (Get-Date) 1826 | return 1827 | } 1828 | 1829 | #If it's older than x days delete disk 1830 | If ( $DeleteOlderThanDays ) { 1831 | #Last Access time isn't always reliable if diff disks are used so lets be safe and use the most recent of access and write 1832 | $mostRecent = $Disk.LastAccessTime, $Disk.LastWriteTime | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum 1833 | if ($mostRecent -lt (Get-Date).AddDays(-$DeleteOlderThanDays) ) { 1834 | try { 1835 | Remove-Item $Disk.FullName -ErrorAction Stop -Force 1836 | Write-VhdOutput -DiskState "Deleted" -FinalSize 0 -EndTime (Get-Date) 1837 | } 1838 | catch { 1839 | Write-VhdOutput -DiskState 'Disk Deletion Failed' -EndTime (Get-Date) 1840 | } 1841 | return 1842 | } 1843 | } 1844 | 1845 | #As disks take time to process, if you have a lot of disks, it may not be worth shrinking the small onesBytes 1846 | if ( $IgnoreLessThanGB -and $originalSize -lt $IgnoreLessThanBytes ) { 1847 | Write-VhdOutput -DiskState 'Ignored' -EndTime (Get-Date) 1848 | return 1849 | } 1850 | 1851 | #Initial disk Mount 1852 | try { 1853 | $mount = Mount-FslDisk -Path $Disk.FullName -TimeOut 30 -PassThru -ErrorAction Stop 1854 | } 1855 | catch { 1856 | $err = $error[0] 1857 | Write-VhdOutput -DiskState $err -EndTime (Get-Date) 1858 | return 1859 | } 1860 | 1861 | #Grabbing partition info can fail when the client is under heavy load so....... 1862 | $timespan = (Get-Date).AddSeconds(120) 1863 | $partInfo = $null 1864 | while (($partInfo | Measure-Object).Count -lt 1 -and $timespan -gt (Get-Date)) { 1865 | try { 1866 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber -ErrorAction Stop | Where-Object -Property 'Type' -EQ -Value 'Basic' -ErrorAction Stop 1867 | } 1868 | catch { 1869 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber -ErrorAction SilentlyContinue | Select-Object -Last 1 1870 | } 1871 | Start-Sleep 0.1 1872 | } 1873 | 1874 | if (($partInfo | Measure-Object).Count -eq 0) { 1875 | $mount | DisMount-FslDisk 1876 | Write-VhdOutput -DiskState 'No Partition Information - The Windows Disk SubSystem did not respond in a timely fashion try increasing number of cores or decreasing threads by using the ThrottleLimit parameter' -EndTime (Get-Date) 1877 | return 1878 | } 1879 | 1880 | $timespan = (Get-Date).AddSeconds(120) 1881 | $defrag = $false 1882 | while ($defrag -eq $false -and $timespan -gt (Get-Date)) { 1883 | try { 1884 | Get-Volume -Partition $partInfo -ErrorAction Stop | Optimize-Volume -ErrorAction Stop 1885 | $defrag = $true 1886 | } 1887 | catch { 1888 | try { 1889 | Get-Volume -ErrorAction Stop | Where-Object { 1890 | $_.UniqueId -like "*$($partInfo.Guid)*" 1891 | -or $_.Path -Like "*$($partInfo.Guid)*" 1892 | -or $_.ObjectId -Like "*$($partInfo.Guid)*" } | Optimize-Volume -ErrorAction Stop 1893 | $defrag = $true 1894 | } 1895 | catch { 1896 | $defrag = $false 1897 | Start-Sleep 0.1 1898 | } 1899 | $defrag = $false 1900 | } 1901 | } 1902 | 1903 | #Grab partition information so we know what size to shrink the partition to and what to re-enlarge it to. This helps optimise-vhd work at it's best 1904 | $partSize = $false 1905 | $timespan = (Get-Date).AddSeconds(30) 1906 | while ($partSize -eq $false -and $timespan -gt (Get-Date)) { 1907 | try { 1908 | $partitionsize = $partInfo | Get-PartitionSupportedSize -ErrorAction Stop 1909 | $sizeMax = $partitionsize.SizeMax 1910 | $partSize = $true 1911 | } 1912 | catch { 1913 | try { 1914 | $partitionsize = Get-PartitionSupportedSize -DiskNumber $mount.DiskNumber -PartitionNumber $mount.PartitionNumber -ErrorAction Stop 1915 | $sizeMax = $partitionsize.SizeMax 1916 | $partSize = $true 1917 | } 1918 | catch { 1919 | $partSize = $false 1920 | Start-Sleep 0.1 1921 | } 1922 | $partSize = $false 1923 | 1924 | } 1925 | } 1926 | 1927 | if ($partSize -eq $false) { 1928 | #$partInfo | Export-Clixml -Path "$env:TEMP\ForJim-$($Disk.Name).xml" 1929 | Write-VhdOutput -DiskState 'No Partition Supported Size Info - The Windows Disk SubSystem did not respond in a timely fashion try increasing number of cores or decreasing threads by using the ThrottleLimit parameter' -EndTime (Get-Date) 1930 | $mount | DisMount-FslDisk 1931 | return 1932 | } 1933 | 1934 | 1935 | #If you can't shrink the partition much, you can't reclaim a lot of space, so skipping if it's not worth it. Otherwise shink partition and dismount disk 1936 | 1937 | if ( $partitionsize.SizeMin -gt $disk.Length ) { 1938 | Write-VhdOutput -DiskState "SkippedAlreadyMinimum" -EndTime (Get-Date) 1939 | $mount | DisMount-FslDisk 1940 | return 1941 | } 1942 | 1943 | 1944 | if (($partitionsize.SizeMin / $disk.Length) -gt (1 - $RatioFreeSpace) ) { 1945 | Write-VhdOutput -DiskState "LessThan$(100*$RatioFreeSpace)%FreeInsideDisk" -EndTime (Get-Date) 1946 | $mount | DisMount-FslDisk 1947 | return 1948 | } 1949 | 1950 | #If I decide to add Hyper-V module support, I'll need this code later 1951 | if ($hyperv -eq $true) { 1952 | 1953 | #In some cases you can't do the partition shrink to the min so increasing by 100 MB each time till it shrinks 1954 | $i = 0 1955 | $resize = $false 1956 | $targetSize = $partitionsize.SizeMin 1957 | $sizeBytesIncrement = 100 * 1024 * 1024 1958 | 1959 | while ($i -le 5 -and $resize -eq $false) { 1960 | try { 1961 | Resize-Partition -InputObject $partInfo -Size $targetSize -ErrorAction Stop 1962 | $resize = $true 1963 | } 1964 | catch { 1965 | $resize = $false 1966 | $targetSize = $targetSize + $sizeBytesIncrement 1967 | $i++ 1968 | } 1969 | finally { 1970 | Start-Sleep 1 1971 | } 1972 | } 1973 | 1974 | #Whatever happens now we need to dismount 1975 | 1976 | if ($resize -eq $false) { 1977 | Write-VhdOutput -DiskState "PartitionShrinkFailed" -EndTime (Get-Date) 1978 | $mount | DisMount-FslDisk 1979 | return 1980 | } 1981 | } 1982 | 1983 | $mount | DisMount-FslDisk 1984 | 1985 | #Change the disk size and grab the new size 1986 | 1987 | $retries = 0 1988 | $success = $false 1989 | #Diskpart is a little erratic and can fail occasionally, so stuck it in a loop. 1990 | while ($retries -lt 30 -and $success -ne $true) { 1991 | 1992 | $tempFileName = "$env:TEMP\FslDiskPart$($Disk.Name).txt" 1993 | 1994 | #Let's put diskpart into a function just so I can use Pester to Mock it 1995 | function invoke-diskpart ($Path) { 1996 | #diskpart needs you to write a txt file so you can automate it, because apparently it's 1989. 1997 | #A better way would be to use optimize-vhd from the Hyper-V module, 1998 | # but that only comes along with installing the actual role, which needs CPU virtualisation extensions present, 1999 | # which is a PITA in cloud and virtualised environments where you can't do Hyper-V. 2000 | #MaybeDo, use hyper-V module if it's there if not use diskpart? two code paths to do the same thing probably not smart though, it would be a way to solve localisation issues. 2001 | Set-Content -Path $Path -Value "SELECT VDISK FILE=`'$($Disk.FullName)`'" 2002 | Add-Content -Path $Path -Value 'attach vdisk readonly' 2003 | Add-Content -Path $Path -Value 'COMPACT VDISK' 2004 | Add-Content -Path $Path -Value 'detach vdisk' 2005 | $result = DISKPART /s $Path 2006 | Write-Output $result 2007 | } 2008 | 2009 | $diskPartResult = invoke-diskpart -Path $tempFileName 2010 | 2011 | #diskpart doesn't return an object (1989 remember) so we have to parse the text output. 2012 | if ($diskPartResult -contains 'DiskPart successfully compacted the virtual disk file.') { 2013 | $finalSize = Get-ChildItem $Disk.FullName | Select-Object -ExpandProperty Length 2014 | $success = $true 2015 | Remove-Item $tempFileName 2016 | } 2017 | else { 2018 | Set-Content -Path "$env:TEMP\FslDiskPartError$($Disk.Name)-$retries.log" -Value $diskPartResult 2019 | $retries++ 2020 | #if DiskPart fails, try, try again. 2021 | } 2022 | Start-Sleep 1 2023 | } 2024 | 2025 | If ($success -ne $true) { 2026 | Write-VhdOutput -DiskState "DiskShrinkFailed" -EndTime (Get-Date) 2027 | Remove-Item $tempFileName 2028 | return 2029 | } 2030 | 2031 | #If I decide to add Hyper-V module support, I'll need this code later 2032 | if ($hyperv -eq $true) { 2033 | #Now we need to reinflate the partition to its previous size 2034 | try { 2035 | $mount = Mount-FslDisk -Path $Disk.FullName -PassThru 2036 | $partInfo = Get-Partition -DiskNumber $mount.DiskNumber | Where-Object -Property 'Type' -EQ -Value 'Basic' 2037 | Resize-Partition -InputObject $partInfo -Size $sizeMax -ErrorAction Stop 2038 | $paramWriteVhdOutput = @{ 2039 | DiskState = "Success" 2040 | FinalSize = $finalSize 2041 | EndTime = Get-Date 2042 | } 2043 | Write-VhdOutput @paramWriteVhdOutput 2044 | } 2045 | catch { 2046 | Write-VhdOutput -DiskState "PartitionSizeRestoreFailed" -EndTime (Get-Date) 2047 | return 2048 | } 2049 | finally { 2050 | $mount | DisMount-FslDisk 2051 | } 2052 | } 2053 | 2054 | 2055 | $paramWriteVhdOutput = @{ 2056 | DiskState = "Success" 2057 | FinalSize = $finalSize 2058 | EndTime = Get-Date 2059 | } 2060 | Write-VhdOutput @paramWriteVhdOutput 2061 | } #Process 2062 | END { } #End 2063 | } #function Optimize-OneDisk 2064 | #Write Output to file and optionally to pipeline 2065 | function Write-VhdOutput { 2066 | [CmdletBinding()] 2067 | 2068 | Param ( 2069 | [Parameter( 2070 | Mandatory = $true 2071 | )] 2072 | [System.String]$Path, 2073 | 2074 | [Parameter( 2075 | Mandatory = $true 2076 | )] 2077 | [System.String]$Name, 2078 | 2079 | [Parameter( 2080 | Mandatory = $true 2081 | )] 2082 | [System.String]$DiskState, 2083 | 2084 | [Parameter( 2085 | Mandatory = $true 2086 | )] 2087 | [System.String]$OriginalSize, 2088 | 2089 | [Parameter( 2090 | Mandatory = $true 2091 | )] 2092 | [System.String]$FinalSize, 2093 | 2094 | [Parameter( 2095 | Mandatory = $true 2096 | )] 2097 | [System.String]$FullName, 2098 | 2099 | [Parameter( 2100 | Mandatory = $true 2101 | )] 2102 | [datetime]$StartTime, 2103 | 2104 | [Parameter( 2105 | Mandatory = $true 2106 | )] 2107 | [datetime]$EndTime, 2108 | 2109 | [Parameter( 2110 | Mandatory = $true 2111 | )] 2112 | [Switch]$Passthru 2113 | ) 2114 | 2115 | BEGIN { 2116 | Set-StrictMode -Version Latest 2117 | } # Begin 2118 | PROCESS { 2119 | 2120 | #unit conversion and calculation should happen in output function 2121 | $output = [PSCustomObject]@{ 2122 | Name = $Name 2123 | StartTime = $StartTime.ToLongTimeString() 2124 | EndTime = $EndTime.ToLongTimeString() 2125 | 'ElapsedTime(s)' = [math]::Round(($EndTime - $StartTime).TotalSeconds, 1) 2126 | DiskState = $DiskState 2127 | OriginalSizeGB = [math]::Round( $OriginalSize / 1GB, 2 ) 2128 | FinalSizeGB = [math]::Round( $FinalSize / 1GB, 2 ) 2129 | SpaceSavedGB = [math]::Round( ($OriginalSize - $FinalSize) / 1GB, 2 ) 2130 | FullName = $FullName 2131 | } 2132 | 2133 | if ($Passthru) { 2134 | Write-Output $output 2135 | } 2136 | $success = $False 2137 | $retries = 0 2138 | while ($retries -lt 10 -and $success -ne $true) { 2139 | try { 2140 | $output | Export-Csv -Path $Path -NoClobber -Append -ErrorAction Stop -NoTypeInformation 2141 | $success = $true 2142 | } 2143 | catch { 2144 | $retries++ 2145 | } 2146 | Start-Sleep 1 2147 | } 2148 | 2149 | 2150 | } #Process 2151 | END { } #End 2152 | } #function Write-VhdOutput.ps1 2153 | 2154 | $paramOptimizeOneDisk = @{ 2155 | Disk = $_ 2156 | DeleteOlderThanDays = $using:DeleteOlderThanDays 2157 | IgnoreLessThanGB = $using:IgnoreLessThanGB 2158 | LogFilePath = $using:LogFilePath 2159 | PassThru = $using:PassThru 2160 | RatioFreeSpace = $using:RatioFreeSpace 2161 | } 2162 | Optimize-OneDisk @paramOptimizeOneDisk 2163 | 2164 | } #Scriptblock 2165 | 2166 | $scriptblockInvokeParallel = { 2167 | 2168 | $disk = $_ 2169 | 2170 | $paramOptimizeOneDisk = @{ 2171 | Disk = $disk 2172 | DeleteOlderThanDays = $DeleteOlderThanDays 2173 | IgnoreLessThanGB = $IgnoreLessThanGB 2174 | LogFilePath = $LogFilePath 2175 | PassThru = $PassThru 2176 | RatioFreeSpace = $RatioFreeSpace 2177 | } 2178 | Optimize-OneDisk @paramOptimizeOneDisk 2179 | 2180 | } #Scriptblock 2181 | 2182 | if ($PSVersionTable.PSVersion -ge [version]"7.0") { 2183 | $diskList | ForEach-Object -Parallel $scriptblockForEachObject -ThrottleLimit $ThrottleLimit 2184 | } 2185 | else { 2186 | $diskList | Invoke-Parallel -ScriptBlock $scriptblockInvokeParallel -Throttle $ThrottleLimit -ImportFunctions -ImportVariables -ImportModules 2187 | } 2188 | 2189 | } #Process 2190 | END { } #End 2191 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 FSLogix, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://dev.azure.com/jimoyle/Invoke-FslShrinkDisk/_apis/build/status/FSLogix.Invoke-FslShrinkDisk?branchName=master)](https://dev.azure.com/jimoyle/Invoke-FslShrinkDisk/_build/latest?definitionId=1&branchName=master) 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/725c8d2481044524b331d3b207971ddf)](https://www.codacy.com/gh/FSLogix/Invoke-FslShrinkDisk?utm_source=github.com&utm_medium=referral&utm_content=FSLogix/Invoke-FslShrinkDisk&utm_campaign=Badge_Grade) 4 | 5 | # Invoke-FslShrinkDisk.ps1 6 | 7 | ## .SYNOPSIS 8 | Shrinks FSLogix Profile and O365 dynamically expanding disk(s). 9 | 10 | ## .DESCRIPTION 11 | FSLogix profile and O365 virtual hard disks are in the vhd or vhdx file format. By default, the disks created will be in Dynamically Expanding format rather than Fixed format. This script does not support reducing the size of a Fixed file format. 12 | 13 | Dynamically Expanding disks do not natively shrink when the volume of data within them reduces, they stay at the 'High water mark' of historical data volume within them. 14 | 15 | This means that Enterprises can wish to reclaim whitespace inside the disks to keep cost down if the storage is cloud based, or make sure they don’t exceed capacity limits if storage is on-premises. 16 | 17 | This Script is designed to work at Enterprise scale to reduce the size of thousands of disks in the shortest time possible. 18 | This script can be run from any machine in your environment it does not need to be run from a file server hosting the disks. It does not need the Hyper-V role installed. 19 | 20 | Powershell version 5.x and 7 and above are supported for this script. It needs to be run as administrator due to the requirement for mounting disks to the OS where the script is run. 21 | 22 | This tool is multi-threaded and will take advantage of multiple CPU cores on the machine from which you run the script. It is not advised to run more than 2x the threads of your available cores on your machine. You could also use the number of threads to throttle the load on your storage. 23 | 24 | Reducing the size of a virtual hard disk is a storage intensive activity. The activity is more in file system metadata operations than pure IOPS, so make sure your storage controllers can handle the load. The storage load occurs on the location where the disks are stored not on the machine where the script is run from. I advise running the script out of hours if possible, to avoid impacting other users on the storage. 25 | 26 | With the intention of reducing the storage load to the minimum possible, you can configure the script to only shrink the disks where you will see the most benefit. You can delete disks which have not been accessed in x number of days previously (configurable). Deletion of disks is not enabled by default. 27 | 28 | By default the script will not run on any disk with less than 5% whitespace inside (configurable). The script can optionally also not run on disks smaller than (x)GB (configurable) as it’s possible that even a large % of whitespace in small disks won’t result in a large capacity reclamation, but even shrinking a small amount of capacity will cause storage load. 29 | The script will output a csv in the following format: 30 | 31 | "Name","DiskState","OriginalSizeGB","FinalSizeGB","SpaceSavedGB","FullName" 32 | "Profile_user1.vhdx","Success","4.35","3.22","1.13",\\Server\Share\ Profile_user1.vhdx " 33 | "Profile_user2.vhdx","Success","4.75","3.12","1.63",\\Server\Share\ Profile_user2.vhdx 34 | 35 | ### Possible Information values for DiskState are as follows 36 | 37 | | DiskState | Meaning | 38 | |-----|-----| 39 | | Success | Disk has been successfully processed and shrunk | 40 | | Ignored | Disk was less than the size configured in -IgnoreLessThanGB parameter | 41 | | Deleted | Disk was last accessed before the number of days configured in the -DeleteOlderThanDays parameter and was successfully deleted | 42 | | DiskLocked | Disk could not be mounted due to being in use | 43 | | LessThan(x)%FreeInsideDisk | Disk contained less whitespace than configured in -RatioFreeSpace parameter and was ignored for processing | 44 | 45 | ### Possible Error values for DiskState are as follows 46 | | DiskState | Meaning | 47 | |-----|-----| 48 | | FileIsNotDiskFormat | Disk file extension was not vhd or vhdx | 49 | | DiskDeletionFailed | Disk was last accessed before the number of days configured in the -DeleteOlderThanDays parameter and was not successfully deleted | 50 | | NoPartitionInfo | Could not get partition information for partition 1 from the disk | 51 | | PartitionShrinkFailed | Failed to Shrink partition as part of the disk processing | 52 | | DiskShrinkFailed | Could not shrink Disk | 53 | | PartitionSizeRestoreFailed | Failed to Restore partition as part of the disk processing | 54 | 55 | If the diskstate shows an error value from the list above, manual intervention may be required to make the disk usable again. 56 | 57 | If you inspect your environment you will probably see that there are a few disks that are consuming a lot of capacity targeting these by using the minimum disk size configuration would be a good step. To grab a list of disks and their sizes from a share you could use this oneliner by replacing < yourshare > with the path to the share containing the disks. 58 | 59 | Get-ChildItem -Path -Filter "*.vhd*" -Recurse -File | Select-Object Name, @{n = 'SizeInGB'; e = {[math]::round($_.length/1GB,2)}} 60 | 61 | All this oneliner does is gather the names and sizes of the virtual hard disks from your share. To export this information to a file readable by excel, use the following replacing both < yourshare > and < yourcsvfile.csv >. You can then open the csv file in excel. 62 | 63 | Get-ChildItem -Path -Filter "*.vhd*" -Recurse -File | Select-Object Name, @{n = 'SizeInGB'; e = {[math]::round($_.length/1GB,2)}} | Export-Csv -Path < yourcsvfile.csv > 64 | 65 | ## .NOTES 66 | Whilst I work for Microsoft and used to work for FSLogix, this is not officially released software from either company. This is purely a personal project designed to help the community. If you require support for this tool please raise an issue on the GitHub repository linked below 67 | 68 | ## .PARAMETER Path 69 | The path to the folder/share containing the disks. You can also directly specify a single disk. UNC paths are supported. 70 | 71 | ## .PARAMETER Recurse 72 | Gets the disks in the specified locations and in all child items of the locations 73 | 74 | ## .PARAMETER IgnoreLessThanGB 75 | The disk size in GB under which the script will not process the file. 76 | 77 | ## .PARAMETER DeleteOlderThanDays 78 | If a disk ‘last access time’ is older than todays date minus this value, the disk will be deleted from the share. This is a permanent action. 79 | 80 | ## .PARAMETER LogFilePath 81 | All disk actions will be saved in a csv file for admin reference. The default location for this csv file is the user’s temp directory. The default filename is in the following format: FslShrinkDisk 2020-04-14 19-36-19.csv 82 | 83 | ## .PARAMETER PassThru 84 | Returns an object representing the item with which you are working. By default, this cmdlet does not generate any pipeline output. 85 | 86 | ## .PARAMETER ThrottleLimit 87 | Specifies the number of disks that will be processed at a time. Further disks in the queue will wait till a previous disk has finished up to a maximum of the ThrottleLimit. The default value is 8. 88 | 89 | ## .PARAMETER RatioFreeSpace 90 | 91 | The minimum percentage of white space in the disk before processing will start as a decimal between 0 and 1 eg 0.2 is 20% 0.65 is 65%. The Default is 0.05. This means that if the available size reduction is less than 5%, then no action will be taken. To try and shrink all files no matter how little the gain set this to 0. 92 | 93 | ## .INPUTS 94 | You can pipe the path into the command which is recognised by type, you can also pipe any parameter by name. It will also take the path positionally 95 | 96 | ## .OUTPUTS 97 | This script outputs a csv file with the result of the disk processing. It will optionally produce a custom object with the same information 98 | 99 | ## .EXAMPLE 100 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path c:\Profile_user1.vhdx 101 | This shrinks a single disk on the local file system 102 | 103 | ## .EXAMPLE 104 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse 105 | This shrinks all disks in the specified share recursively 106 | 107 | ## .EXAMPLE 108 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -IgnoreLessThanGB 3 109 | This shrinks all disks in the specified share recursively, except for files under 3GB in size which it ignores. 110 | 111 | ## .EXAMPLE 112 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -DeleteOlderThanDays 90 113 | This shrinks all disks in the specified share recursively and deletes disks which were not accessed within the last 90 days. 114 | 115 | ## .EXAMPLE 116 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -LogFilePath C:\MyLogFile.csv 117 | This shrinks all disks in the specified share recursively and changes the default log file location to C:\MyLogFile.csv 118 | 119 | ## .EXAMPLE 120 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -PassThru 121 | 122 | Name: Profile_user1.vhdx 123 | DiskState: Success 124 | OriginalSizeGB: 4.35 125 | FinalSizeGB: 3.22 126 | SpaceSavedGB: 1.13 127 | FullName: \\Server\Share\ Profile_user1.vhdx 128 | 129 | This shrinks all disks in the specified share recursively and passes the result of the disk processing to the pipeline as an object as well as saving the results in a csv in the default location. 130 | 131 | ## .EXAMPLE 132 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -ThrottleLimit 20 133 | This shrinks all disks in the specified share recursively increasing the number of threads used to 20 from the default 8. 134 | 135 | ## .EXAMPLE 136 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -RatioFreeSpace 0.3 137 | This shrinks all disks in the specified share recursively while not processing disks which have less than 30% whitespace instead of the default 15%. 138 | 139 | ## .EXAMPLE 140 | C:\PS> Invoke-FslShrinkDisk.ps1 -Path \\server\share -Recurse -PassThru IgnoreLessThanGB 3 -DeleteOlderThanDays 90 -LogFilePath C:\MyLogFile.csv -ThrottleLimit 20 -RatioFreeSpace 0.3 141 | This does all of the above examples, but together. 142 | 143 | ## .LINK 144 | 145 | -------------------------------------------------------------------------------- /Tests/Private/Dismount-FslDisk.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | 3 | $here = Split-Path -Parent $PSCommandPath 4 | $funcType = Split-Path $here -Leaf 5 | $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.' 6 | $here = $here | Split-Path -Parent | Split-Path -Parent 7 | . "$here\Functions\$funcType\$sut" 8 | } 9 | 10 | Describe "Describing Dismount-FslDisk" { 11 | 12 | BeforeAll { 13 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 14 | $Path = 'Testdrive:\NotPath' 15 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 16 | $imPath = 'Testdrive:\NotImage' 17 | } 18 | 19 | 20 | Context "Input" { 21 | BeforeAll { 22 | Mock -CommandName Dismount-DiskImage -MockWith { $null } 23 | Mock -CommandName Remove-Item -MockWith { $null } 24 | Mock -CommandName Get-DiskImage -MockWith { [PSCustomObject]@{ 25 | Attached = $false 26 | } 27 | } 28 | } 29 | 30 | It "Takes input via param" { 31 | Dismount-FslDisk -Path $Path -ImagePath $imPath -ErrorAction Stop | Should -BeNullOrEmpty 32 | } 33 | 34 | It "Takes input via param with passthru" { 35 | Dismount-FslDisk -Path $Path -ImagePath $imPath -Passthru -ErrorAction Stop | Select-Object -ExpandProperty MountRemoved | Should -Be $true 36 | } 37 | 38 | It "Takes input via pipeline" { 39 | $Path | Dismount-FslDisk -ImagePath $imPath -ErrorAction Stop | Should -BeNullOrEmpty 40 | } 41 | 42 | It "Takes input via named pipeline" { 43 | [PSCustomObject]@{ 44 | Path = $Path 45 | ImagePath = $imPath 46 | } | Dismount-FslDisk -ErrorAction Stop | Should -BeNullOrEmpty 47 | } 48 | } 49 | 50 | Context "Function Logic" { 51 | BeforeAll { 52 | Mock -CommandName Dismount-DiskImage -MockWith { $null } 53 | Mock -CommandName Remove-Item -MockWith { $null } 54 | Mock -CommandName Get-DiskImage -MockWith { [PSCustomObject]@{ 55 | Attached = $false 56 | } 57 | } 58 | 59 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 60 | $param = @{ 61 | Path = $Path 62 | ImagePath = $imPath 63 | Passthru = $true 64 | ErrorAction = 'Stop' 65 | } 66 | } 67 | 68 | It "It Reports Mount removed as true" { 69 | Dismount-FslDisk @param | Select-Object -ExpandProperty MountRemoved | Should -Be $true 70 | } 71 | 72 | It "It Reports Directory removed as true" { 73 | Dismount-FslDisk @param | Select-Object -ExpandProperty DirectoryRemoved | Should -Be $true 74 | } 75 | 76 | It "It writes verbose line if successful" { 77 | $verBoseOut = Dismount-FslDisk -Path $Path -ImagePath $imPath -Verbose -ErrorAction Stop 4>&1 78 | $verBoseOut | Should -Be "Dismounted $imPath" 79 | } 80 | 81 | } 82 | 83 | Context "Output error Directory" { 84 | 85 | BeforeAll { 86 | Mock -CommandName Dismount-DiskImage -MockWith { $null } 87 | Mock -CommandName Remove-Item -MockWith { Write-Error 'RemoveMock' } 88 | Mock -CommandName Get-DiskImage -MockWith { [PSCustomObject]@{ 89 | Attached = $false 90 | } 91 | } 92 | 93 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 94 | $param = @{ 95 | Path = $Path 96 | ImagePath = $imPath 97 | Passthru = $true 98 | ErrorAction = 'SilentlyContinue' 99 | } 100 | } 101 | 102 | It "It Reports Directory removed as false" { 103 | Dismount-FslDisk @param | Select-Object -ExpandProperty DirectoryRemoved | Should -Be $false 104 | } 105 | } 106 | 107 | Context "Output error Mount" { 108 | 109 | BeforeAll { 110 | Mock -CommandName Dismount-DiskImage -MockWith { Write-Error 'DismountMock' } 111 | Mock -CommandName Remove-Item -MockWith { $null } 112 | Mock -CommandName Get-DiskImage -MockWith { [PSCustomObject]@{ 113 | Attached = $false 114 | } 115 | } 116 | 117 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 118 | $param = @{ 119 | Path = $Path 120 | ImagePath = $imPath 121 | Passthru = $true 122 | ErrorAction = 'SilentlyContinue' 123 | Timeout = 2 124 | } 125 | } 126 | 127 | It "It Reports Mount removed as false" { 128 | Dismount-FslDisk @param | Select-Object -ExpandProperty MountRemoved | Should -Be $false 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /Tests/Private/Mount-FslDisk.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | $here = Split-Path -Parent $PSCommandPath 3 | $funcType = Split-Path $here -Leaf 4 | $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.' 5 | $here = $here | Split-Path -Parent | Split-Path -Parent 6 | . "$here\Functions\$funcType\$sut" 7 | } 8 | 9 | Describe "Describing Mount-FslDisk" { 10 | 11 | BeforeAll{ 12 | $Path = 'TestDrive:\ThisDoesNotExist.vhdx' 13 | 14 | Mock -CommandName Mount-DiskImage -MockWith { 15 | [pscustomobject]@{ 16 | Attached = $True 17 | BlockSize = 33554432 18 | DevicePath = '\\.\PhysicalDrive4' 19 | FileSize = 4668260352 20 | ImagePath = $Path 21 | LogicalSectorSize = 512 22 | Number = 4 23 | Size = 31457280000 24 | StorageType = 3 25 | PSComputerName = $null 26 | CimClass = 'ROOT/Microsoft/Windows/Storage:MSFT_DiskImage' 27 | CimInstanceProperties = '{ Attached, BlockSize, DevicePath, FileSize… }' 28 | CimSystemProperties = 'Microsoft.Management.Infrastructure.CimSystemProperties' 29 | PSTypeName = 'Microsoft.Management.Infrastructure.CimInstance#ROOT/Microsoft/Windows/Storage/MSFT_DiskImage' 30 | } 31 | } 32 | Mock -CommandName Get-DiskImage -MockWith { 33 | [pscustomobject]@{ 34 | Attached = $True 35 | BlockSize = 33554432 36 | DevicePath = '\\.\PhysicalDrive4' 37 | FileSize = 4668260352 38 | ImagePath = $Path 39 | LogicalSectorSize = 512 40 | Number = 4 41 | Size = 31457280000 42 | StorageType = 3 43 | PSComputerName = $null 44 | PSTypeName = 'Microsoft.Management.Infrastructure.CimInstance#ROOT/Microsoft/Windows/Storage/MSFT_DiskImage' 45 | } 46 | } 47 | Mock -CommandName Get-Partition -MockWith { 48 | [pscustomobject]@{ 49 | PartitionNumber = 1 50 | Offset = 0 51 | Type = 'Basic' 52 | Size = 31457280000 53 | PSComputerName = $null 54 | PSTypeName = 'Microsoft.Management.Infrastructure.CimInstance#ROOT/Microsoft/Windows/Storage/MSFT_Partition' 55 | } 56 | } 57 | } 58 | 59 | Context "Input" { 60 | 61 | BeforeAll { 62 | Mock -CommandName New-Item -MockWith { $null } 63 | Mock -CommandName Dismount-DiskImage -MockWith { $null } 64 | Mock -CommandName Add-PartitionAccessPath -MockWith { $null } 65 | Mock -CommandName Remove-Item -MockWith { $null } 66 | Mock -CommandName Join-Path -MockWith { 'TestDrive:\mountHere' } 67 | } 68 | 69 | It "Takes input via param" { 70 | Mount-FslDisk -Path $Path -ErrorAction Stop | Should -BeNullOrEmpty 71 | } 72 | 73 | It "Takes input via param with passthru" { 74 | Mount-FslDisk -Path $Path -Passthru -ErrorAction Stop | Select-Object -ExpandProperty ImagePath | Should -Be $Path 75 | } 76 | 77 | It "Takes input via param Alias" { 78 | Mount-FslDisk -FullName $Path -ErrorAction Stop | Should -BeNullOrEmpty 79 | } 80 | 81 | It "Takes input via pipeline" { 82 | $Path | Mount-FslDisk -ErrorAction Stop | Should -BeNullOrEmpty 83 | } 84 | 85 | It "Takes input via named pipeline" { 86 | [PSCustomObject]@{ 87 | Path = $Path 88 | } | Mount-FslDisk -ErrorAction Stop | Should -BeNullOrEmpty 89 | } 90 | 91 | It "Takes input via named pipeline alias" { 92 | [PSCustomObject]@{ 93 | FullName = $Path 94 | } | Mount-FslDisk -ErrorAction Stop | Should -BeNullOrEmpty 95 | } 96 | 97 | It "Takes input positionally" { 98 | Mount-FslDisk $Path -ErrorAction Stop | Should -BeNullOrEmpty 99 | } 100 | } 101 | 102 | Context "Logic" { 103 | 104 | BeforeAll { 105 | Mock -CommandName New-Item -MockWith { $null } 106 | Mock -CommandName Dismount-DiskImage -MockWith { $null } 107 | Mock -CommandName Add-PartitionAccessPath -MockWith { $null } 108 | Mock -CommandName Remove-Item -MockWith { $null } 109 | Mock -CommandName Join-Path -MockWith { 'TestDrive:\mountHere' } 110 | } 111 | 112 | 113 | It "It produces a mount path" { 114 | Mount-FslDisk -Path $Path -Passthru -ErrorAction Stop | Select-Object -ExpandProperty ImagePath | Should -Be $path 115 | } 116 | 117 | It "It produces a disknumber" { 118 | Mount-FslDisk -Path $Path -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskNumber | Should -Be 4 119 | } 120 | 121 | It "It produces a Path" { 122 | Mount-FslDisk -Path $Path -Passthru -ErrorAction Stop | Select-Object -ExpandProperty Path | Should -Be 'TestDrive:\mountHere' 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /Tests/Private/Optimize-OneDisk.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | $here = Split-Path -Parent $PSCommandPath 3 | $funcType = Split-Path $here -Leaf 4 | $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.' 5 | $here = $here | Split-Path -Parent | Split-Path -Parent 6 | . "$here\Functions\$funcType\$sut" 7 | 8 | #Import functions so they can be used or mocked 9 | . "$here\Functions\Private\Write-VhdOutput.ps1" 10 | . "$here\Functions\Private\Mount-FslDisk.ps1" 11 | . "$here\Functions\Private\Dismount-FslDisk.ps1" 12 | 13 | #Adding enpty function so that the mock works 14 | function invoke-diskpart ($Path) { 15 | 16 | } 17 | 18 | } 19 | 20 | 21 | 22 | Describe "Describing Optimize-OneDisk" { 23 | 24 | BeforeAll { 25 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 26 | $disk = New-Item testdrive:\fakedisk.vhdx | Get-ChildItem 27 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 28 | $notDisk = New-Item testdrive:\fakeextension.vhdx.txt | Get-ChildItem 29 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 30 | $DeleteOlderThanDays = 90 31 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 32 | $IgnoreLessThanGB = $null 33 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 34 | $LogFilePath = 'TestDrive:\log.csv' 35 | $SizeMax = 4668260352 36 | 37 | Mock -CommandName Mount-FslDisk -MockWith { [PSCustomObject]@{ 38 | Path = 'TestDrive:\nothere.vhdx' 39 | DiskNumber = 4 40 | ImagePath = 'Testdrive:\nopath' 41 | } 42 | } 43 | Mock -CommandName Get-PartitionSupportedSize -MockWith { [PSCustomObject]@{ 44 | SizeMin = 3379200645 45 | SizeMax = $SizeMax 46 | } 47 | } 48 | Mock -CommandName Get-ChildItem -MockWith { $disk } 49 | Mock -CommandName Remove-Item -MockWith { $null } 50 | Mock -CommandName Resize-Partition -MockWith { $null } -ParameterFilter { $Size -ne $SizeMax } 51 | Mock -CommandName Resize-Partition -MockWith { $null } 52 | Mock -CommandName DisMount-FslDisk -MockWith { $null } 53 | Mock -CommandName Start-Sleep -MockWith { $null } 54 | } 55 | 56 | 57 | 58 | Context "Input" { 59 | BeforeAll{ 60 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 61 | $paramShrinkOneDisk = @{ 62 | Disk = $notdisk 63 | DeleteOlderThanDays = $DeleteOlderThanDays 64 | IgnoreLessThanGB = $IgnoreLessThanGB 65 | LogFilePath = $LogFilePath 66 | RatioFreeSpace = 0.2 67 | } 68 | } 69 | 70 | It "Takes input via param" { 71 | Optimize-OneDisk @paramShrinkOneDisk -ErrorAction Stop | Should -BeNullOrEmpty 72 | } 73 | 74 | It "Takes input via param with passthru" { 75 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty Name | Should -Be 'fakeextension.vhdx.txt' 76 | } 77 | 78 | It "Takes input via pipeline for disk" { 79 | 80 | $paramShrinkOneDisk = @{ 81 | DeleteOlderThanDays = $DeleteOlderThanDays 82 | IgnoreLessThanGB = $IgnoreLessThanGB 83 | LogFilePath = $LogFilePath 84 | RatioFreeSpace = 0.2 85 | } 86 | $notdisk | Optimize-OneDisk @paramShrinkOneDisk -ErrorAction Stop | Should -BeNullOrEmpty 87 | } 88 | 89 | It "Takes input via named pipeline" { 90 | $pipeShrinkOneDisk = [pscustomobject]@{ 91 | Disk = $notdisk 92 | DeleteOlderThanDays = $DeleteOlderThanDays 93 | IgnoreLessThanGB = $IgnoreLessThanGB 94 | LogFilePath = $LogFilePath 95 | RatioFreeSpace = 0.2 96 | } 97 | $pipeShrinkOneDisk | Optimize-OneDisk -ErrorAction Stop | Should -BeNullOrEmpty 98 | } 99 | 100 | } 101 | 102 | Context "Failed delete" { 103 | 104 | BeforeAll { 105 | Mock -CommandName Remove-Item -MockWith { Write-Error 'Nope' } 106 | Mock -CommandName Get-Partition -MockWith { $null } 107 | Mock -CommandName Get-Volume -MockWith { $null } 108 | Mock -CommandName Optimize-Volume -MockWith { $null } 109 | 110 | $disk.LastAccessTime = (Get-Date).AddDays(-2) 111 | 112 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 113 | $paramShrinkOneDisk = @{ 114 | Disk = $disk 115 | DeleteOlderThanDays = 1 116 | IgnoreLessThanGB = $IgnoreLessThanGB 117 | LogFilePath = $LogFilePath 118 | RatioFreeSpace = 0.2 119 | } 120 | } 121 | 122 | It "Gives right output when no deletion" -Skip { 123 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'DiskDeletionFailed' 124 | } 125 | } 126 | 127 | Context "Not Disk" { 128 | 129 | It "Gives right output when not disk" { 130 | $paramShrinkOneDisk = @{ 131 | Disk = $notDisk 132 | DeleteOlderThanDays = $DeleteOlderThanDays 133 | IgnoreLessThanGB = $IgnoreLessThanGB 134 | LogFilePath = $LogFilePath 135 | RatioFreeSpace = 0.2 136 | } 137 | 138 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'File Is Not a Virtual Hard Disk format with extension vhd or vhdx' 139 | } 140 | } 141 | 142 | Context "Too Small" { 143 | 144 | It "Gives right output disk is too small" { 145 | $paramShrinkOneDisk = @{ 146 | Disk = $Disk 147 | DeleteOlderThanDays = $DeleteOlderThanDays 148 | IgnoreLessThanGB = 5 149 | LogFilePath = $LogFilePath 150 | RatioFreeSpace = 0.2 151 | } 152 | 153 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'Ignored' 154 | } 155 | } 156 | 157 | Context "Locked" { 158 | 159 | It "Gives right output when disk is Locked" -Skip { 160 | $errtxt = 'Disk in use' 161 | Mock -CommandName Mount-FslDisk -MockWith { Write-Error $errtxt } 162 | 163 | $paramShrinkOneDisk = @{ 164 | Disk = $Disk 165 | DeleteOlderThanDays = $DeleteOlderThanDays 166 | IgnoreLessThanGB = $IgnoreLessThanGB 167 | LogFilePath = $LogFilePath 168 | RatioFreeSpace = 0.2 169 | } 170 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be $errtxt 171 | } 172 | } 173 | 174 | Context "No Partition" { 175 | 176 | It "Gives right output when No Partition" -Skip { 177 | Mock -CommandName Get-PartitionSupportedSize -MockWith { Write-Error 'Nope' } 178 | Mock -CommandName Get-Partition -MockWith { $null } 179 | Mock -CommandName Get-Volume -MockWith { $null } 180 | Mock -CommandName Optimize-Volume -MockWith { $null } 181 | 182 | $paramShrinkOneDisk = @{ 183 | Disk = $Disk 184 | DeleteOlderThanDays = $DeleteOlderThanDays 185 | IgnoreLessThanGB = $IgnoreLessThanGB 186 | LogFilePath = $LogFilePath 187 | RatioFreeSpace = 0.2 188 | } 189 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'NoPartitionInfo' 190 | } 191 | } 192 | 193 | Context "Shrink Partition Fail" { 194 | 195 | It "Gives right output when Shrink Partition Fail" -Skip { 196 | 197 | Mock -CommandName Resize-Partition -MockWith { Write-Error 'Nope' } -ParameterFilter { $Size -ne $SizeMax } 198 | 199 | $paramShrinkOneDisk = @{ 200 | Disk = $Disk 201 | DeleteOlderThanDays = $DeleteOlderThanDays 202 | IgnoreLessThanGB = $IgnoreLessThanGB 203 | LogFilePath = $LogFilePath 204 | RatioFreeSpace = 0.2 205 | } 206 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'PartitionShrinkFailed' 207 | } 208 | } 209 | 210 | Context "No Partition Space" { 211 | 212 | It "Gives right output when No Partition Space" -Skip { 213 | $paramShrinkOneDisk = @{ 214 | Disk = $Disk 215 | DeleteOlderThanDays = $DeleteOlderThanDays 216 | IgnoreLessThanGB = $IgnoreLessThanGB 217 | LogFilePath = $LogFilePath 218 | RatioFreeSpace = 0.5 219 | } 220 | 221 | $out = Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState 222 | $out | Should -Be LessThan$(100*$paramShrinkOneDisk.RatioFreeSpace)%FreeInsideDisk 223 | } 224 | } 225 | 226 | Context "Shrink Disk Fail" { 227 | 228 | It "Gives right output when Shrink Disk Fail" -Skip { 229 | 230 | Mock -CommandName invoke-diskpart -MockWith { $null } 231 | 232 | $paramShrinkOneDisk = @{ 233 | Disk = $Disk 234 | DeleteOlderThanDays = $DeleteOlderThanDays 235 | IgnoreLessThanGB = $IgnoreLessThanGB 236 | LogFilePath = $LogFilePath 237 | RatioFreeSpace = 0.2 238 | } 239 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'DiskShrinkFailed' 240 | } 241 | } 242 | 243 | Context "Restore Partition size Fail" { 244 | 245 | It "Gives right output when estore Partition size Fail" -Skip { 246 | Mock -CommandName Resize-Partition -MockWith { Write-Error 'nope' } -ParameterFilter { $Size -eq $SizeMax } 247 | Mock -CommandName invoke-diskpart -MockWith { , 'DiskPart successfully compacted the virtual disk file.' } 248 | 249 | $paramShrinkOneDisk = @{ 250 | Disk = $Disk 251 | DeleteOlderThanDays = $DeleteOlderThanDays 252 | IgnoreLessThanGB = $IgnoreLessThanGB 253 | LogFilePath = $LogFilePath 254 | RatioFreeSpace = 0.2 255 | } 256 | Optimize-OneDisk @paramShrinkOneDisk -Passthru -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'PartitionSizeRestoreFailed' 257 | } 258 | } 259 | 260 | Context "Output" { 261 | BeforeAll{ 262 | Mock -CommandName Resize-Partition -MockWith { $null } -ParameterFilter { $Size -eq $SizeMax } 263 | Mock -CommandName invoke-diskpart -MockWith { , 'DiskPart successfully compacted the virtual disk file.' } 264 | 265 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 266 | $paramShrinkOneDisk = @{ 267 | DeleteOlderThanDays = $DeleteOlderThanDays 268 | IgnoreLessThanGB = $IgnoreLessThanGB 269 | RatioFreeSpace = 0.2 270 | } 271 | 272 | } 273 | 274 | It "Gives right output when Shink Successful" -Skip { 275 | Optimize-OneDisk @paramShrinkOneDisk -LogFilePath $LogFilePath -Passthru -Disk $Disk -ErrorAction Stop | Select-Object -ExpandProperty DiskState | Should -Be 'Success' 276 | } 277 | 278 | It "Saves correct information in a csv" -Skip { 279 | Optimize-OneDisk @paramShrinkOneDisk -Disk $Disk -ErrorAction Stop -LogFilePath 'TestDrive:\OutputTest.csv' 280 | Import-Csv 'TestDrive:\OutputTest.csv' | Select-Object -ExpandProperty DiskState | Should -Be 'Success' 281 | } 282 | 283 | It "Appends information in a csv" -Skip { 284 | Optimize-OneDisk @paramShrinkOneDisk -ErrorAction Stop -LogFilePath 'TestDrive:\AppendTest.csv' -Disk $Disk 285 | Optimize-OneDisk @paramShrinkOneDisk -ErrorAction Stop -LogFilePath 'TestDrive:\AppendTest.csv' -Disk $NotDisk 286 | Import-Csv 'TestDrive:\AppendTest.csv' | Measure-Object | Select-Object -ExpandProperty Count | Should -Be 2 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Tests/Private/Test-FslDependencies.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | $here = Split-Path -Parent $PSCommandPath 3 | $funcType = Split-Path $here -Leaf 4 | $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.' 5 | $here = $here | Split-Path -Parent | Split-Path -Parent 6 | . "$here\Functions\$funcType\$sut" 7 | } 8 | 9 | Describe "Describing Test-FslDependencies" { 10 | 11 | BeforeAll { 12 | Mock -CommandName Set-Service -MockWith { 13 | $null 14 | } 15 | Mock -CommandName Start-Service -MockWith { 16 | $null 17 | } 18 | } 19 | 20 | Context "Input" { 21 | BeforeAll { 22 | Mock -CommandName Get-Service -MockWith { 23 | [PSCustomObject]@{ 24 | Status = "Running" 25 | StartupType = "Disabled" 26 | } 27 | } 28 | } 29 | 30 | It "Takes input via param" { 31 | Test-FslDependencies -Name NullService | Should -BeNullOrEmpty 32 | } 33 | 34 | It "Takes input via pipeline" { 35 | "NullService" | Test-FslDependencies | Should -BeNullOrEmpty 36 | } 37 | 38 | It "Takes multiple services as parameter input" { 39 | Test-FslDependencies -Name "NullService", 'NotService' | Should -BeNullOrEmpty 40 | } 41 | 42 | It "Takes multiple services as pipeline input" { 43 | "NullService", 'NotService' | Test-FslDependencies | Should -BeNullOrEmpty 44 | } 45 | } 46 | 47 | Context "Logic" { 48 | BeforeAll { 49 | Mock -CommandName Get-Service -MockWith { 50 | [PSCustomObject]@{ 51 | Status = "Stopped" 52 | StartType = "Disabled" 53 | DisplayName = 'Blah' 54 | } 55 | } 56 | } 57 | 58 | It "Sets to manual" -Skip { 59 | Test-FslDependencies -Name NullService | Should -BeNullOrEmpty 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /Tests/Private/TestsReadme.md: -------------------------------------------------------------------------------- 1 | # No tests needed for Invoke-Parallel as it has tests on it's own repo -------------------------------------------------------------------------------- /Tests/Private/Write-VhdOutput.tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | 3 | $here = Split-Path -Parent $PSCommandPath 4 | $funcType = Split-Path $here -Leaf 5 | $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.' 6 | $here = $here | Split-Path -Parent | Split-Path -Parent 7 | . "$here\Functions\$funcType\$sut" 8 | } 9 | 10 | Describe "Describing Write-VhdOutput" { 11 | 12 | BeforeAll { 13 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUserDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] 14 | $param = @{ 15 | Path = "TestDrive:\ICareNot.csv" 16 | Name = 'Jim.vhdx' 17 | DiskState = 'Amazing' 18 | OriginalSize = 40 * 1024 * 1024 * 1024 19 | FinalSize = 1 * 1024 * 1024 * 1024 20 | FullName = "TestDrive:\Jim.vhdx" 21 | Passthru = $true 22 | Starttime = Get-Date 23 | EndTime = (Get-Date).AddSeconds(20) 24 | } 25 | } 26 | 27 | It "Does not error" { 28 | 29 | Write-VhdOutput @param -ErrorAction Stop 30 | } 31 | 32 | It 'Calculates Elapsed time' { 33 | $r = Write-VhdOutput @param 34 | $r.'ElapsedTime(s)' | Should -Be 20 35 | } 36 | 37 | It 'Calculates Elapsed time' { 38 | $r = Write-VhdOutput @param 39 | $r.SpaceSavedGB | Should -Be 39 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /Tests/Public/Invoke-FslShrinkDisk.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | $here = Split-Path -Parent $PSCommandPath 3 | $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.' 4 | $here = $here | Split-Path -Parent | Split-Path -Parent 5 | $script = Get-Content "$here\$sut" 6 | $tstdrvPath = "$env:temp\Invoke-FslShrinkDisk.ps1" 7 | Set-Content 'function Invoke-FslShrinkDisk {' -Path $tstdrvPath 8 | Add-Content -Path $tstdrvPath $script 9 | Add-Content -Path $tstdrvPath '}' 10 | . "$here\Functions\Private\Invoke-Parallel" 11 | . "$here\Functions\Private\Mount-FslDisk" 12 | . "$here\Functions\Private\Dismount-FslDisk" 13 | . "$here\Functions\Private\Optimize-OneDisk" 14 | . "$here\Functions\Private\Write-VhdOutput" 15 | function Test-FslDependencies ($Service, $InputObject) {} 16 | . $tstdrvPath 17 | } 18 | 19 | Describe 'Invoke-FslShrinkDisk' { 20 | BeforeAll { 21 | $time = [datetime]'12:00' 22 | $out = [PSCustomObject]@{ 23 | Path = 'TestDrive:\log.csv' 24 | StartTime = $time 25 | EndTime = $time.AddSeconds(30) 26 | Name = 'FakeDisk.vhd' 27 | DiskState = 'Success' 28 | OriginalSize = 20 * 1024 * 1024 * 1024 29 | FinalSize = 3 * 1024 * 1024 * 1024 30 | FullName = 'TestDrive:\FakeDisk.vhd' 31 | Passthru = $true 32 | } 33 | Mock -CommandName Mount-FslDisk -MockWith { 34 | [PSCustomObject]@{ 35 | Path = 'TestDrive:\Temp\FSlogixMnt-38abe060-2cb4-4cf2-94f3-19128901a9f6' 36 | DiskNumber = 3 37 | ImagePath = 'TestDrive:\FakeDisk.vhd' 38 | } 39 | } 40 | Mock -CommandName Get-CimInstance -MockWith { 41 | [PSCustomObject]@{ 42 | NumberOfLogicalProcessors = 4 43 | } 44 | } 45 | Mock -CommandName Get-ChildItem -MockWith { 46 | [PSCustomObject]@{ 47 | FullName = 'TestDrive:\FakeDisk.vhd' 48 | Name = 'FakeDisk.vhd' 49 | } 50 | } 51 | Mock -CommandName Test-FslDependencies -MockWith { $null } 52 | Mock -CommandName Dismount-FslDisk -MockWith { $null } 53 | Mock -CommandName Write-VhdOutput -MockWith { $null } 54 | Mock -CommandName Test-Path -MockWith { $true } 55 | Mock -CommandName Optimize-OneDisk -MockWith { $out } 56 | Mock -CommandName Invoke-Parallel -MockWith { $out } 57 | Mock -CommandName ForEach-Object -MockWith { $out } 58 | } 59 | 60 | It "Does not error" { 61 | Invoke-FslShrinkDisk -Path 'TestDrive:\FakeDisk.vhd' -ErrorAction Stop 62 | } 63 | 64 | It 'Takes Input via pipeline'{ 65 | 'TestDrive:\FakeDisk.vhd' | Invoke-FslShrinkDisk 66 | } 67 | } -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Starter pipeline 2 | # Start with a minimal pipeline that you can customize to build and deploy your code. 3 | # Add steps that build, run tests, deploy, and more: 4 | # https://aka.ms/yaml 5 | 6 | trigger: 7 | - master 8 | 9 | pool: 10 | vmImage: "windows-2019" 11 | 12 | steps: 13 | - task: PowerShell@2 14 | displayName: "Install Newest Pester Version" 15 | inputs: 16 | targetType: "inline" # Optional. Options: filePath, inline 17 | #filePath: # Required when targetType == FilePath 18 | #arguments: # Optional 19 | script: Install-Module -Name Pester -Force -SkipPublisherCheck 20 | # Required when targetType == Inline 21 | #errorActionPreference: 'stop' # Optional. Options: stop, continue, silentlyContinue 22 | #failOnStderr: false # Optional 23 | #ignoreLASTEXITCODE: false # Optional 24 | #pwsh: false # Optional 25 | #workingDirectory: # Optional 26 | 27 | - task: PowerShell@2 28 | displayName: "Run Pester Tests for Script" 29 | inputs: 30 | targetType: "inline" # Optional. Options: filePath, inline 31 | #filePath: # Required when targetType == FilePath 32 | #arguments: # Optional 33 | script: Invoke-Pester -OutputFile PesterTest.xml -OutputFormat NUnitXml -CodeCoverage (Get-ChildItem .\functions -File -Recurse -Include *.ps1 | ? {$_.Name -ne 'Invoke-Parallel.ps1'}) -CodeCoverageOutputFile CodeCoverage.xml # Required when targetType == Inline 34 | #errorActionPreference: 'stop' # Optional. Options: stop, continue, silentlyContinue 35 | #failOnStderr: false # Optional 36 | #ignoreLASTEXITCODE: false # Optional 37 | #pwsh: false # Optional 38 | #workingDirectory: # Optional 39 | 40 | - task: PublishTestResults@2 41 | inputs: 42 | testRunTitle: "Pester Test Results" 43 | buildPlatform: "Windows" 44 | testRunner: "NUnit" 45 | testResultsFiles: '.\PesterTest.xml' 46 | mergeTestResults: false 47 | failTaskOnFailedTests: true 48 | 49 | - task: PublishCodeCoverageResults@1 50 | inputs: 51 | codeCoverageTool: "JaCoCo" # Options: cobertura, jaCoCo 52 | summaryFileLocation: '.\CodeCoverage.xml' 53 | #reportDirectory: # Optional 54 | #additionalCodeCoverageFiles: # Optional 55 | failIfCoverageEmpty: true # Optional 56 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | #DO NOT USE THIS EXCEPT FOR CREATING THE FINAL SCRIPT 2 | 3 | 4 | function Add-FslRelease { 5 | [cmdletbinding()] 6 | param ( 7 | [Parameter( 8 | ValuefromPipelineByPropertyName = $true 9 | )] 10 | [System.String]$FunctionsFolder = '.\Functions', 11 | 12 | [Parameter( 13 | ValuefromPipelineByPropertyName = $true 14 | )] 15 | [System.String]$ReleaseFile, 16 | [Parameter( 17 | ValuefromPipelineByPropertyName = $true 18 | )] 19 | [System.String]$ControlScript 20 | ) 21 | 22 | $ctrlScript = Get-Content -Path $ControlScript 23 | 24 | $funcs = Get-ChildItem $FunctionsFolder -File | Where-Object { $_.Name -ne $ControlScript } 25 | 26 | foreach ($funcName in $funcs) { 27 | 28 | $pattern = "#$($funcName.BaseName)" 29 | $pattern = ". .\Functions\Private\$($funcName.BaseName)" 30 | $actualFunc = Get-Content $funcName.FullName 31 | 32 | $ctrlScript = $ctrlScript | Foreach-Object { 33 | 34 | if ($_ -like "*$pattern*" ) { 35 | $actualFunc 36 | } 37 | else { 38 | $_ 39 | } 40 | } 41 | } 42 | $ctrlScript | Set-Content $ReleaseFile 43 | } 44 | $path = 'C:\PoShCode\Invoke-FslShrinkDisk' 45 | $p = @{ 46 | 47 | FunctionsFolder = Join-Path $path 'Functions\Private' 48 | ReleaseFile = Join-Path $path 'Invoke-FslShrinkDisk.ps1' 49 | ControlScript = Join-Path $path 'Functions\Public\Invoke-FslShrinkDisk.ps1' 50 | } 51 | 52 | Add-FslRelease @p --------------------------------------------------------------------------------