Get-CMUnusedSources.ps1
<#PSScriptInfo
.VERSION 1.0 .GUID 62980d1d-d263-4c01-b49c-e64502363127 .AUTHOR Adam Cook (Twitter: @codaamok - website: cookadam.co.uk) .COMPANYNAME .COPYRIGHT .TAGS SCCM ConfigMgr ConfigurationManager .LICENSEURI https://github.com/codaamok/Get-CMUnusedSources/blob/master/LICENSE .PROJECTURI https://github.com/codaamok/Get-CMUnusedSources .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES #> <# .SYNOPSIS Get-CMUnusedSources will tell you what folders are not used by ConfigMgr in a given path. See https://github.com/codaamok/Get-CMUnusedSources for documentation. .DESCRIPTION This script will tell you what folders are used or not used in a given path by a site in System Center Configuration Manager. It leverages the ConfigMgr PowerShell cmdlets to gather content object source path information so you need the console installed. The script can be run remotely from a site server, e.g. your desktop. A PSObject is returned with the UsedBy status of each folder under the given parameter -SourcesLocation. You can also specify -HtmlReport generated by the module PSWriteHtml, from there you can export to PDF, CSV or XLSX. See the GitHub repository README for the documentation https://github.com/codaamok/Get-CMUnusedSources and my blog with a personal account of me writing this script https://www.cookadam.co.uk/get-cmunusedsources. .PARAMETER SourcesLocation The path to the directory you store your ConfigMgr sources. Can be a UNC or local path. Must be a valid path that you have read access to. .PARAMETER SiteCode The site code of the ConfigMgr site you wish to query for content objects. .PARAMETER SiteServer The site server of the given ConfigMgr site code. The server must be reachable over a network. .PARAMETER Packages Specify this switch to include Packages within the search to determine unused content on disk. .PARAMETER Applications Specify this switch to include Applications within the search to determine unused content on disk. .PARAMETER Drivers Specify this switch to include Drivers within the search to determine unused content on disk. .PARAMETER DriverPackages Specify this switch to include DriverPackages within the search to determine unused content on disk. .PARAMETER OSImages Specify this switch to include OSImages within the search to determine unused content on disk. .PARAMETER OSUpgradeImages Specify this switch to include OSUpgradeImages within the search to determine unused content on disk. .PARAMETER BootImages Specify this switch to include BootImages within the search to determine unused content on disk. .PARAMETER DeploymentPackages Specify this switch to include DeploymentPackages within the search to determine unused content on disk. .PARAMETER AltFolderSearch Specify this if you suspect there are issue with the default mechanism of gathering folders, which is: Get-ChildItem -LiteralPath "\\?\UNC\server\share\folder" -Directory -Recurse | Select-Object -ExpandProperty FullName .PARAMETER NoProgress Specify this to disable use of Write-Progress. .PARAMETER Log Specify this to enable logging. The log file(s) will be saved to the same directory as this script with a name of <scriptname>_<datetime>.log. Rolled log files will follow a naming convention of <filename>_1.lo_ where the int increases for each rotation. Each maximum log file is 2MB. .PARAMETER ExportReturnObject Specify this option if you wish to export the PowerShell return object to an XML file. The XML file be saved to the same directory as this script with a name of <scriptname>_<datetime>_result.xml. It can easily be reimported using Import-Clixml cmdlet. .PARAMETER ExportCMContentObjects Specify this option if you wish to export all ConfigMgr content objects to an XML file. The XML file be saved to the same directory as this script with a name of <scriptname>_<datetime>_cmobjects.xml. It can easily be reimported using Import-Clixml cmdlet. .PARAMETER HtmlReport Specify this option to enable the generation for a HTML report of the result. Doing this will force you to have the PSWriteHtml module installed. For more information on PSWriteHTML: https://github.com/EvotecIT/PSWriteHTML. The HTML file will be saved to the same directory as this script with a name of <scriptname>_<datetime>.html. .PARAMETER Threads Set the number of threads you wish to use for concurrent processing of this script. Default value is number of processes from env var NUMBER_OF_PROCESSORS. .INPUTS .OUTPUTS .EXAMPLE C:\> $result = .\Get-CMUnusedSources.ps1 -SourcesLocation \\sccm\Applications$ -SiteCode ACC -SiteServer SCCM -Applications -Log -LogFileSize 10MB -NumOfRotatedLogs 5 -ExportReturnObject -HtmlReport -Threads 2 .EXAMPLE C:\> $result = .\Get-CMUnusedSources.ps1 -SourcesLocation F:\ -SiteCode ACC -SiteServer SCCM -Log -HtmlReport .NOTES Author: Adam Cook (@codaamok) Updated: 11/08/2019 License: GLP-3.0 Source: https://github.com/codaamok/Get-CMUnusedSources #> #Requires -Version 5.1 Param ( [Parameter(Mandatory=$true, Position = 0, HelpMessage="Valid path (local or remote0 to where you store you ConfigMgr sources.")] [ValidateScript({ If (!([System.IO.Directory]::Exists($_))) { throw "Invalid path or access denied" } ElseIf (!($_ | Test-Path -PathType Container)) { throw "Value must be a directory, not a file" } Else { return $true } })] [string]$SourcesLocation, [Parameter(Mandatory=$true, Position = 1, HelpMessage="ConfigMgr site code you are querying.")] [ValidatePattern('^[a-zA-Z0-9]{3}$')] [string]$SiteCode, [Parameter(Mandatory=$true, Position = 2, HelpMessage="ConfigMgr site server of the site site code.")] [ValidateScript({ If(!(Test-Connection -ComputerName $_ -Count 1 -ErrorAction SilentlyContinue)) { throw "Host `"$($_)`" is unreachable" } Else { return $true } })] [string]$SiteServer, [Parameter(Mandatory=$false, HelpMessage="Gather packages.")] [switch]$Packages, [Parameter(Mandatory=$false, HelpMessage="Gather applications.")] [switch]$Applications, [Parameter(Mandatory=$false, HelpMessage="Gather drivers.")] [switch]$Drivers, [Parameter(Mandatory=$false, HelpMessage="Gather driver packages.")] [switch]$DriverPackages, [Parameter(Mandatory=$false, HelpMessage="Gather Operating System images.")] [switch]$OSImages, [Parameter(Mandatory=$false, HelpMessage="Gather Operating System upgrade images.")] [switch]$OSUpgradeImages, [Parameter(Mandatory=$false, HelpMessage="Gather boot images.")] [switch]$BootImages, [Parameter(Mandatory=$false, HelpMessage="Gather deployment packages.")] [switch]$DeploymentPackages, [Parameter(Mandatory=$false, HelpMessage="Enable alternative folder search.")] [switch]$AltFolderSearch, [Parameter(Mandatory=$false, HelpMessage="Disable use of Write-Progress.")] [switch]$NoProgress, [Parameter(Mandatory=$false, HelpMessage="Enable logging.")] [switch]$Log, [Parameter(Mandatory=$false, HelpMessage="Generate XML export of PowerShell object with the result.")] [switch]$ExportReturnObject, [Parameter(Mandatory=$false, HelpMessage="Generate XML export of PowerShell object with all ConfigMgr content objects.")] [switch]$ExportCMContentObjects, [Parameter(Mandatory=$false, HelpMessage="Generate HTML report of the result.")] [switch]$HtmlReport, [Parameter(Mandatory=$false, HelpMessage="Number of threads to use for execution.")] [int32]$Threads = $env:NUMBER_OF_PROCESSORS ) <# TODO: - $SiteServer should be validated - omg stupid hard - Exclude folders parameter in get-childitem? - publish to technet - delete log entries for $result?? - if given F:\ or \\server\f$ currently Get-AllPaths does not determine shared folders that match the path used #> <# Define PSDefaultParameterValues and other variables #> $JobId = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' # Write-CMLogEntry $PSDefaultParameterValues["Write-CMLogEntry:Bias"]=(Get-WmiObject -Class Win32_TimeZone | Select-Object -ExpandProperty Bias) $PSDefaultParameterValues["Write-CMLogEntry:Folder"]=($PSCommandPath | Split-Path -Parent) $PSDefaultParameterValues["Write-CMLogEntry:FileName"]=(($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + ".log") $PSDefaultParameterValues["Write-CMLogEntry:Enable"]=$Log.IsPresent $PSDefaultParameterValues["Write-CMLogEntry:MaxLogFileSize"]=2MB $PSDefaultParameterValues["Write-CMLogEntry:MaxNumOfRotatedLogs"]=0 $PSDefaultParameterValues["New-HTMLContent:SelectorColor"]="DeepSkyBlue" $PSDefaultParameterValues["New-HTMLContent:HeaderBackGroundColor"]="DeepSkyBlue" $PSDefaultParameterValues["New-HTMLTable:DisableColumnReorder"]=$true $PSDefaultParameterValues["New-HTMLTable:ScrollX"]=$true $PSDefaultParameterValues["New-HTMLTable:TextWhenNoData"]="None" <# Define functions #> Function Write-CMLogEntry { <# .SYNOPSIS Write to log file in CMTrace friendly format. .DESCRIPTION Half of the code in this function is Cody Mathis's. I added log rotation and some other bits, with help of Chris Dent for some sorting and regex. Should find this code on the WinAdmins GitHub repo for configmgr. .OUTPUTS Writes to $Folder\$FileName and/or standard output. .LINK https://github.com/winadminsdotorg/SystemCenterConfigMgr #> param ( [parameter(Mandatory = $true, HelpMessage = 'Value added to the log file.')] [ValidateNotNullOrEmpty()] [string]$Value, [parameter(Mandatory = $false, HelpMessage = 'Severity for the log entry. 1 for Informational, 2 for Warning and 3 for Error.')] [ValidateNotNullOrEmpty()] [ValidateSet('1', '2', '3')] [string]$Severity = 1, [parameter(Mandatory = $false, HelpMessage = "Stage that the log entry is occuring in, log refers to as 'component'.")] [ValidateNotNullOrEmpty()] [string]$Component, [parameter(Mandatory = $true, HelpMessage = 'Name of the log file that the entry will written to.')] [ValidateNotNullOrEmpty()] [string]$FileName, [parameter(Mandatory = $true, HelpMessage = 'Path to the folder where the log will be stored.')] [ValidateNotNullOrEmpty()] [string]$Folder, [parameter(Mandatory = $false, HelpMessage = 'Set timezone Bias to ensure timestamps are accurate.')] [ValidateNotNullOrEmpty()] [int32]$Bias, [parameter(Mandatory = $false, HelpMessage = 'Maximum size of log file before it rolls over. Set to 0 to disable log rotation.')] [ValidateNotNullOrEmpty()] [int32]$MaxLogFileSize = 0, [parameter(Mandatory = $false, HelpMessage = 'Maximum number of rotated log files to keep. Set to 0 for unlimited rotated log files.')] [ValidateNotNullOrEmpty()] [int32]$MaxNumOfRotatedLogs = 0, [parameter(Mandatory = $true, HelpMessage = 'A switch that enables the use of this function.')] [ValidateNotNullOrEmpty()] [switch]$Enable, [switch]$WriteHost ) # Runs this regardless of $Enable value If ($WriteHost.IsPresent -eq $true) { Write-Host $Value } If ($Enable.IsPresent -eq $true) { # Determine log file location $LogFilePath = Join-Path -Path $Folder -ChildPath $FileName If ((([System.IO.FileInfo]$LogFilePath).Exists) -And ($MaxLogFileSize -ne 0)) { # Get log size in bytes $LogFileSize = [System.IO.FileInfo]$LogFilePath | Select-Object -ExpandProperty Length If ($LogFileSize -ge $MaxLogFileSize) { # Get log file name without extension $LogFileNameWithoutExt = $FileName -replace ([System.IO.Path]::GetExtension($FileName)) # Get already rolled over logs $RolledLogs = "{0}_*" -f $LogFileNameWithoutExt $AllLogs = Get-ChildItem -Path $Folder -Name $RolledLogs -File # Sort them numerically (so the oldest is first in the list) $AllLogs = $AllLogs | Sort-Object -Descending { $_ -replace '_\d+\.lo_$' }, { [Int]($_ -replace '^.+\d_|\.lo_$') } ForEach ($Log in $AllLogs) { # Get log number $LogFileNumber = [int32][Regex]::Matches($Log, "_([0-9]+)\.lo_$").Groups[1].Value switch (($LogFileNumber -eq $MaxNumOfRotatedLogs) -And ($MaxNumOfRotatedLogs -ne 0)) { $true { # Delete log if it breaches $MaxNumOfRotatedLogs parameter value $DeleteLog = Join-Path $Folder -ChildPath $Log [System.IO.File]::Delete($DeleteLog) } $false { # Rename log to +1 $Source = Join-Path -Path $Folder -ChildPath $Log $NewFileName = $Log -replace "_([0-9]+)\.lo_$",("_{0}.lo_" -f ($LogFileNumber+1)) $Destination = Join-Path -Path $Folder -ChildPath $NewFileName [System.IO.File]::Copy($Source, $Destination, $true) } } } # Copy main log to _1.lo_ $NewFileName = "{0}_1.lo_" -f $LogFileNameWithoutExt $Destination = Join-Path -Path $Folder -ChildPath $NewFileName [System.IO.File]::Copy($LogFilePath, $Destination, $true) # Blank the main log $StreamWriter = [System.IO.StreamWriter]::new($LogFilePath, $false) $StreamWriter.Close() } } # Construct time stamp for log entry switch -regex ($Bias) { '-' { $Time = [string]::Concat($(Get-Date -Format 'HH:mm:ss.fff'), $Bias) } Default { $Time = [string]::Concat($(Get-Date -Format 'HH:mm:ss.fff'), '+', $Bias) } } # Construct date for log entry $Date = (Get-Date -Format 'MM-dd-yyyy') # Construct context for log entry $Context = $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) # Construct final log entry $LogText = [string]::Format('<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="{4}" type="{5}" thread="{6}" file="">', $Value, $Time, $Date, $Component, $Context, $Severity, $PID) # Add value to log file try { $StreamWriter = [System.IO.StreamWriter]::new($LogFilePath, 'Append') $StreamWriter.WriteLine($LogText) $StreamWriter.Close() } catch [System.Exception] { Write-Warning -Message ("Unable to append log entry to {0} file. Error message: {1}" -f $FileName, $_.Exception.Message) } } } Function Get-CMContent { [CmdletBinding()] <# .SYNOPSIS Get all ConfigMgr objects that can hold content, i.e. content objects. .DESCRIPTION Using the ConfigMgr PoSH cmdlets, in the $Commands array, get all content objects and filter them to the given site code. For each content object, create a PSCustomObject with the needed properties. Called by main body. .OUTPUTS System.Object.PSCustomObject #> Param( [Parameter(Mandatory=$true,ValueFromPipeline=$true)] [string[]]$Commands, [Parameter(Mandatory=$true,ValueFromPipeline=$false)] [string]$SiteServer, [Parameter(Mandatory=$true,ValueFromPipeline=$false)] [string]$SiteCode ) Begin { [hashtable]$ShareCache = @{} } Process { ForEach ($Command in $Commands) { Write-CMLogEntry -Value ("Gathering: {0}" -f $Command -replace "Get-CM") -Severity 1 -Component "GatherContentObjects" # Filter by site code $Command = $Command + " | Where-Object SourceSite -eq `"{0}`"" -f $SiteCode ForEach ($item in (Invoke-Expression $Command)) { switch -regex ($Command) { "^Get-CMApplication.+" { $AppMgmt = [xml]$item.SDMPackageXML | Select-Object -ExpandProperty AppMgmtDigest ForEach ($DeploymentType in $AppMgmt.DeploymentType) { $SourcePaths = $DeploymentType.Installer.Contents.Content.Location # Using ForEach-Object because even if $SourcePaths is null, it will iterate null once which is ideal here where deployment types can have no source path. # Also, A deployment type can have more than 1 source path: for install and uninstall paths $SourcePaths | ForEach-Object { $SourcePath = $_ # Get every possible path $GetAllPathsResult = Get-AllPaths -Path $SourcePath -Cache $ShareCache -SiteServer $SiteServer # Create content object PSObject with needed properties and add to array $obj = [PSCustomObject]@{ ContentType = "Application" UniqueID = $DeploymentType | Select-Object -ExpandProperty LogicalName Name = "{0}::{1}" -f $item.LocalizedDisplayName,$DeploymentType.Title.InnerText SourcePath = $SourcePath SourcePathFlag = [int](Test-FileSystemAccess -Path $SourcePath -Rights Read) AllPaths = $GetAllPathsResult[1] } $obj } # Maintaining cache of shared folders for servers encountered so far $ShareCache = $GetAllPathsResult[0] Write-CMLogEntry -Value ("{0} - {1} - {2} - {3} - {4} - {5}" -f $obj.ContentType,$obj.UniqueID,$obj.Name,$obj.SourcePath,$obj.SourcePathFlag,($obj.AllPaths.Keys -join ",")) -Severity 1 -Component "GatherContentObjects" } } "^Get-CMDriver\s.+" { $SourcePath = $item.ContentSourcePath # Get every possible path $GetAllPathsResult = Get-AllPaths -Path $SourcePath -Cache $ShareCache -SiteServer $SiteServer # Create content object PSObject with needed properties and add to array $obj = [PSCustomObject]@{ ContentType = "Driver" UniqueID = $item.CI_ID Name = $item.LocalizedDisplayName SourcePath = $SourcePath SourcePathFlag = [int](Test-FileSystemAccess -Path $SourcePath -Rights Read) AllPaths = $GetAllPathsResult[1] } $obj # Maintaining cache of shared folders for servers encountered so far $ShareCache = $GetAllPathsResult[0] Write-CMLogEntry -Value ("{0} - {1} - {2} - {3} - {4} - {5}" -f $obj.ContentType,$obj.UniqueID,$obj.Name,$obj.SourcePath,$obj.SourcePathFlag,($obj.AllPaths.Keys -join ",")) -Severity 1 -Component "GatherContentObjects" } default { # OS images and boot iamges are absolute paths to files If (($Command -match ("^Get-CMOperatingSystemImage.+")) -Or ($Command -match ("^Get-CMBootImage.+"))) { $SourcePath = Split-Path $item.PkgSourcePath } Else { $SourcePath = $item.PkgSourcePath } $ContentType = ([Regex]::Matches($Command, "Get-CM([^\s]+)")).Groups[1].Value # Get every possible path $GetAllPathsResult = Get-AllPaths -Path $SourcePath -Cache $ShareCache -SiteServer $SiteServer # Create content object PSObject with needed properties and add to array $obj = [PSCustomObject]@{ ContentType = $ContentType UniqueID = $item.PackageId Name = $item.Name SourcePath = $SourcePath SourcePathFlag = [int](Test-FileSystemAccess -Path $SourcePath -Rights Read) AllPaths = $GetAllPathsResult[1] } $obj # Maintaining cache of shared folders for servers encountered so far $ShareCache = $GetAllPathsResult[0] Write-CMLogEntry -Value ("{0} - {1} - {2} - {3} - {4} - {5}" -f $obj.ContentType,$obj.UniqueID,$obj.Name,$obj.SourcePath,$obj.SourcePathFlag,($obj.AllPaths.Keys -join ",")) -Severity 1 -Component "GatherContentObjects" } } } Write-CMLogEntry -Value ("Done gathering: {0}" -f ($Command -replace "Get-CM").Split(" ")[0]) -Severity 1 -Component "GatherContentObjects" } } } Function Get-AllPaths { <# .SYNOPSIS Determine all possible paths for a given path. .DESCRIPTION For a given path, determine all other possible path combinations that ultimately point back to $Path. Useful to determine the local path for a given UNC path (in turn get the UNC path that uses the drive $ share), or if a there are multiple shared folders pointing to the same location. Called by Get-CMContent. .OUTPUTS System.Object.List with always only two elements; $AllPaths (hashtable, the calculated list of "all paths" for the given $Path), and $Cache (hashtable, the shared folders cache). The first element ($Cache, hashtable) of the $result collection is dedicated to being a cache which will contain a list of all servers (key) and a hashtable (value) for a list of shared folder names and their local paths. The second element ($AllPath, hashtable) of the $result collection contains the list of all possible paths associated with the given $Path (key) and the NetBIOS server name (value) of which it belongs to. .EXAMPLE PS C:\> Get-AllPaths -Path "\\SCCM\Applications$\7-zip" -Cache $SharedFolderCache -SiteServer "SCCM" Name Value ---- ----- 192.168.175.11 {Folder, UpdateServicesPackages, EasySetupPayload, SMSSIG$...} sccm {Folder, UpdateServicesPackages, EasySetupPayload, SMSSIG$...} sccm.acc.local {Folder, UpdateServicesPackages, EasySetupPayload, SMSSIG$...} \\192.168.175.11\Applications$ sccm \\192.168.175.11\F$\Applica... sccm \\sccm.acc.local\Applications$ sccm \\sccm\Applications$ sccm \\sccm\DiffFolder1$ sccm \\sccm.acc.local\F$\Applica... sccm \\192.168.175.11\DiffFolder1$ sccm F:\Applications sccm \\sccm.acc.local\DiffFolder1$ sccm \\sccm\F$\Applications sccm #> param ( [string]$Path, [hashtable]$Cache, [string]$SiteServer ) [System.Collections.Generic.List[Object]]$result = @() [hashtable]$AllPaths = @{} If (([string]::IsNullOrEmpty($Path) -eq $false) -And ($Path -notmatch "^[a-zA-Z]:\\$")) { $Path = $Path.TrimEnd("\") } ##### Determine path type switch ($true) { ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z]\$)$") { # Path that is \\server\f$ $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$null $PathType = 4 break } ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z]\$)(\\[a-zA-Z0-9`~\\!@#$%^&(){}\'._ -]+)") { # Path that is \\server\f$\folder $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$Matches[3] $PathType = 3 break } ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z0-9`~!@#$%^&(){}\'._ -]+)$") { # Path that is \\server\share $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$null $PathType = 2 break } ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z0-9`~!@#$%^&(){}\'._ -]+)(\\[a-zA-Z0-9`~\\!@#$%^&(){}\'._ -]+)") { # Path that is \\server\share\folder $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$Matches[3] $PathType = 1 break } ($Path -match "^[a-zA-Z]:\\") { # Path that is local # Script does not determine UNC / shared folder paths if the content object source path is a local path $AllPaths.Add($Path, $SiteServer) $result.Add($Cache) $result.Add($AllPaths) return $result } ([string]::IsNullOrEmpty($Path) -eq $true) { # If there is no source path, just return now with $AllPaths empty $result.Add($Cache) $result.Add($AllPaths) return $result } default { # Please share $Path with me if this is caught! # As a fail safe, abort $Message = "Unable to interpret path `"{0}`"" -f $Path Write-Warning $Message Write-CMLogEntry -Value $Message -Severity 2 -Component "GatherContentObjects" $AllPaths.Add($Path, $null) $result.Add($Cache) $result.Add($AllPaths) return $result } } ##### Determine FQDN, IP and NetBIOS # Only determine if you have a record # Might be annoying if $Server is an IP, unreachable and revese lookup succeeds If (Test-Connection -ComputerName $Server -Count 1 -ErrorAction SilentlyContinue) { If ($Server -as [IPAddress]) { try { # Reverse lookup $FQDN = [System.Net.Dns]::GetHostEntry($Server) | Select-Object -ExpandProperty HostName $NetBIOS = $FQDN.Split(".")[0] } catch { # In case no record $FQDN = $null } $IP = $Server } Else { try { # Get FQDN even if $Server is FQDN, so we cut out $NetBIOS and resolve for $IP $FQDN = [System.Net.Dns]::GetHostByName($Server) | Select-Object -ExpandProperty HostName $NetBIOS = $FQDN.Split(".")[0] } catch { # In case no record $FQDN = $null } $IP = (((Test-Connection $Server -Count 1 -ErrorAction SilentlyContinue)).IPV4Address).IPAddressToString } } Else { # Won't be able to query Win32_Class if unreachable so no point continuing Write-CMLogEntry -Value ("Server `"{0}`" is unreachable" -f $Server) -Severity 2 -Component "GatherContentObjects" $AllPaths.Add($Path, $null) $result.Add($Cache) $result.Add($AllPaths) return $result } ##### Update the cache of shared folders and their local paths If (($Cache.Keys -contains $FQDN) -eq $false) { # Do not yet have this server's shares cached # $AllSharedFolders is null if couldn't connect to serverr to get all shared folders $AllSharedFolders = Get-AllSharedFolders -Server $FQDN If ([string]::IsNullOrEmpty($AllSharedFolders) -eq $false) { $NetBIOS,$FQDN,$IP | Where-Object { [string]::IsNullOrEmpty($_) -eq $false } | ForEach-Object { $Cache.Add($_, $AllSharedFolders) } } Else { # Add null so on the next encounter of a server from a given UNC path, we don't wastefully try again $NetBIOS,$FQDN,$IP | Where-Object { [string]::IsNullOrEmpty($_) -eq $false } | ForEach-Object { $Cache.Add($_, $null) } Write-CMLogEntry -Value ("Could not update cache because could not get shared folders from: `"{0}`"" -f $FQDN) -Severity 2 -Component "GatherContentObjects" } } ##### Build the AllPaths property [System.Collections.Generic.List[String]]$AllPathsArr = @() $NetBIOS,$FQDN,$IP | Where-Object { [string]::IsNullOrEmpty($_) -eq $false } | ForEach-Object -Process { $AltServer = $_ If ($Cache.$AltServer -ne $null) { # Get the share's local path $LocalPath = $Cache.$AltServer.GetEnumerator().Where( { $_.Key -eq $ShareName } ) | Select-Object -ExpandProperty Value } Else { $LocalPath = $null } # If \\server\f$ then $LocalPath is "F:" If ([string]::IsNullOrEmpty($LocalPath) -eq $false) { If ($PathType -match "1|2") { # Add \\server\f$\path\to\shared\folder\on\disk $AllPathsArr.Add(("\\$($AltServer)\$($LocalPath)$($ShareRemainder)" -replace ':', '$')) # Get other shared folders that point to the same path and add them to the AllPaths array $SharesWithSamePath = $Cache.$AltServer.GetEnumerator().Where( { $_.Value -eq $LocalPath } ) | Select-Object -ExpandProperty Key ForEach ($AltShareName in $SharesWithSamePath) { $AllPathsArr.Add("\\$($AltServer)\$($AltShareName)$($ShareRemainder)") } } } Else { Write-CMLogEntry -Value ("Could not resolve share `"{0}`" on `"{1}`" from cache, either because it does not exist or could not query Win32_Share on server" -f $ShareName,$_) -Severity 2 -Component "GatherContentObjects" } # Add the original path again but with the alternate server (FQDN / NetBIOS / IP) $AllPathsArr.Add("\\$($AltServer)\$($ShareName)$($ShareRemainder)") } -End { If ([string]::IsNullOrEmpty($LocalPath) -eq $false) { # Either of the below are important in case user is running local to site server and gave local path as $SourcesLocation If (($LocalPath -match "^[a-zA-Z]:$") -And ($PathType -match "2|4")) { # Match if just a drive letter (WHY?!) and add it to AllPaths array # This occurs if path type is 2 and the share points to root of a volume $AllPathsArr.Add("$($LocalPath)\") } Else { # Add the local path to AllPaths array $AllPathsArr.Add("$($LocalPath)$($ShareRemainder)") } } } # Add all that's inside the AllPaths array to the AllPaths hashtable # Unfotunately adding stuff to hash table that already exists in there can be noisy to stderr in console ForEach ($item in $AllPathsArr) { If (($AllPaths.Keys -notcontains $item) -eq $true) { $AllPaths.Add($item, $NetBIOS) } } $result.Add($Cache) $result.Add($AllPaths) return $result } Function Get-AllSharedFolders { <# .SYNOPSIS Get all shared folders hosted on a server. .DESCRIPTION Query Win32_Share WMI class on $Server and return a hashtable result. Called by Get-AllPaths. .OUTPUTS System.Object.Hashtable where a list of shared folder names (key) and their local paths (value). #> Param([String]$Server) [hashtable]$AllShares = @{} try { $Shares = (Get-WmiObject -ComputerName $Server -Class Win32_Share -ErrorAction Stop).Where( {-not [string]::IsNullOrEmpty($_.Path)} ) ForEach ($Share in $Shares) { # The TrimEnd method is only really concerned for drive letter shares # as they're usually stored as f$ = "F:\" and this messes up Get-AllPaths a little $AllShares.Add($Share.Name, $Share.Path.TrimEnd("\")) } } catch { $AllShares = $null } return $AllShares } Function Get-AllFolders { <# .SYNOPSIS Recrusively get all folders under $Path. .DESCRIPTION Get all folders in $Path. By default this function escapes the max path limit by prefixing $Path with the following: "\\?\UNC". This is what _mostly_ the driver for the PoSH 5.1 requirement. Called by main body. .OUTPUTS System.Object.Generic.List[String] of folder full names. #> Param( [string]$Path, [bool]$DiffFolderSearch ) # Prefix the path the user gives us with \\?\ to avoid the 260 MAX_PATH limit # More info https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation switch ($true) { ($Path -match "^\\\\[a-zA-Z0-9`~!@#$%^&(){}\'._-]+\\[a-zA-Z0-9\\`~!@#$%^&(){}\'._ -]+") { # Matches if it's a UNC path # Could have queried .IsUnc property on [System.Uri] object but I wanted to verify user hadn't first given us \\?\ path type $Path = $Path -replace "^\\\\", "\\?\UNC\" break } ($Path -match "^[a-zA-Z]:\\") { # Matches if starts with drive letter $Path = "\\?\" + $Path break } default { $Message = "Couldn't determine path type for `"{0}`" so might have problems accessing folders that breach MAX_PATH limit, quitting..." -f $Path Write-CMLogEntry -Value $Message -Severity 2 -Component "GatherFolders" throw $Message } } # Recursively get all folders If ($DiffFolderSearch -eq $true) { [System.Collections.Generic.List[String]]$Folders = Start-AltFolderSearch -FolderName $Path } Else { try { [System.Collections.Generic.List[String]]$Folders = Get-ChildItem -LiteralPath $Path -Directory -Recurse | Select-Object -ExpandProperty FullName } catch { $Message = "Consider using -AltFolderSearch, quiting..." Write-CMLogEntry -Value $Message -Severity 3 -Component "GatherFolders" throw $Message } } If ([string]::IsNullOrEmpty($Folders) -eq $true) { [System.Collections.Generic.List[String]]$Folders = @($Path) } Else { $Folders.Add($Path) } # Undo the \\?\ prefix switch ($true) { ($Path -match "^\\\\\?\\UNC\\") { # Matches if starts with \\?\UNC\ $Folders = $Folders -replace [Regex]::Escape("\\?\UNC\"), "\\" break } ($Path -match "^\\\\\?\\[a-zA-Z]{1}:\\") { # Matches if starts with \\?\A:\ (A is just an example drive letter used) $Folders = $Folders -replace [Regex]::Escape("\\?\"), "" break } default { # For some reason, couldn't undo \\?\ prefix. If you get this, please share $Path with me! # No big deal though, can keep going. $SourcesLocation will stay as what the user gave in the parent scope Write-CMLogEntry -Value ("Couldn't reset {0}" -f $Path) -Severity 3 -Component "GatherFolders" } } $Folders = $Folders | Sort-Object return $Folders } Function Start-AltFolderSearch { <# .SYNOPSIS Get all folders under $FolderName, but not recursively. .DESCRIPTION Get all folders under $FolderName but does not recursively get all folders for each and every child funder. This exists because in some environments Get-ChildItem would throw an exception "Not enough quota is available to process this command.". FullyQualifiedErrorId: "DirIOError,Microsoft.PowerShell.Commands.GetChildItemCommand". While investigating the exception was thrown at around the 50k size of any collection type and packet traces showed SMBv1 packets returning similar exception message as by PoSH, some sort of quota limit. Further testing on different storage systems using SMBv1 this exception was not reproducable. Massive thanks to Chris Kibble for coming up with this work around and time to help troubleshoot! Called by Get-AllFolders. .OUTPUTS System.Object.Generic.List[String] of folder full names. #> Param([string]$FolderName) # Annoyingly, Get-ChildItem with forced output to an arry @(Get-ChildItem ...) can return an explicit # $null value for folders with no subfolders, causing the for loop to indefinitely iterate through # working dir when it reaches a null value, so is null check is needed [System.Collections.Generic.List[String]]$Folders = @((Get-ChildItem -Path $FolderName -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName).Where( { [string]::IsNullOrEmpty($_) -eq $false } )) ForEach($Folder in $Folders) { $Folders.Add($(Start-AltFolderSearch -FolderName $Folder)) } return $Folders } Function Test-FileSystemAccess { <# .SYNOPSIS Check for file system access on a given folder. .OUTPUTS [System.Enum] ERROR_SUCCESS (0) ERROR_PATH_NOT_FOUND (3) ERROR_ACCESS_DENIED (5) ERROR_ELEVATION_REQUIRED (740) .NOTES Authors: Patrick Seymour / Adam Cook Contact: @pseymour / @codaamok #> param ( [Parameter(Mandatory=$false)] [string]$Path, [Parameter(Mandatory=$true)] [System.Security.AccessControl.FileSystemRights]$Rights ) enum FileSystemAccessState { ERROR_SUCCESS ERROR_PATH_NOT_FOUND = 3 ERROR_ACCESS_DENIED = 5 ERROR_ELEVATION_REQUIRED = 740 } [System.Security.Principal.WindowsIdentity]$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() [System.Security.Principal.WindowsPrincipal]$currentPrincipal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) $IsElevated = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $IsInAdministratorsGroup = $currentIdentity.Claims.Value -contains "S-1-5-32-544" if ([System.IO.Directory]::Exists($Path)) { try { [System.Security.AccessControl.FileSystemSecurity]$security = (Get-Item -Path ("FileSystem::{0}" -f $Path) -Force).GetAccessControl() if ($security -ne $null) { [System.Security.AccessControl.AuthorizationRuleCollection]$rules = $security.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) for([int]$i = 0; $i -lt $rules.Count; $i++) { if (($currentIdentity.Groups.Contains($rules[$i].IdentityReference)) -or ($currentIdentity.User -eq $rules[$i].IdentityReference)) { [System.Security.AccessControl.FileSystemAccessRule]$fileSystemRule = [System.Security.AccessControl.FileSystemAccessRule]$rules[$i] if ($fileSystemRule.FileSystemRights.HasFlag($Rights)) { return [FileSystemAccessState]::ERROR_SUCCESS } } } if (($IsElevated -eq $false) -And ($IsInAdministratorsGroup -eq $true) -And ($rules.Where( { ($_.IdentityReference -eq "S-1-5-32-544") -And ($_.FileSystemRights.HasFlag($Rights)) } ))) { # At this point we were able to read ACL and verify Administrators group access, likely because we were qualified by the object set as owner return [FileSystemAccessState]::ERROR_ELEVATION_REQUIRED } else { return [FileSystemAccessState]::ERROR_ACCESS_DENIED } } else { return [FileSystemAccessState]::ERROR_ACCESS_DENIED } } catch { return [FileSystemAccessState]::ERROR_ACCESS_DENIED } } else { return [FileSystemAccessState]::ERROR_PATH_NOT_FOUND } } function Measure-ChildItem { <# .SYNOPSIS Recursively measures the size of a directory. .NOTES Author: Chris Dent (indented-automation) https://github.com/indented-automation Source: https://github.com/steviecoaster/PSSysadminToolkit/blob/Dev/Public/Measure-ChildItem.ps1 MIT license. http://www.opensource.org/licenses/MIT #> [CmdletBinding()] param ( # The path to measure the size of. Accepts pipeline input. By default the size of the current working directory is measured. [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [String]$Path = $pwd, # The units sizes should be displayed in. By default, sizes are displayed in Bytes. [ValidateSet('B', 'KB', 'MB', 'GB', 'TB')] [String]$Unit = 'B', # When rounding, the number of digits to display after a decimal point. By defaut sizes are rounded to two decimal places. [ValidateRange(0, 28)] [Int32]$Digits = 2, # Return the size value only, discards file, and directory counts and path information. [Switch]$ValueOnly ) begin { if (-not ('SC.IO.FileSearcher' -as [Type])) { Add-Type ' using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; namespace SC.IO { [StructLayout(LayoutKind.Sequential)] public struct FILETIME { public uint dwLowDateTime; public uint dwHighDateTime; }; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct WIN32_FIND_DATA { public FileAttributes dwFileAttributes; public FILETIME ftCreationTime; public FILETIME ftLastAccessTime; public FILETIME ftLastWriteTime; public int nFileSizeHigh; public int nFileSizeLow; public int dwReserved0; public int dwReserved1; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] public string cAlternate; } public class UnsafeNativeMethods { [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindFirstFile(string lpFileName, out WIN32_FIND_DATA lpFindFileData); [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindFirstFileExW( string lpFileName, int fInfoLevelId, out WIN32_FIND_DATA lpFindFileData, int fSearchOp, IntPtr lpSearchFilter, int dwAdditionalFlags ); [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] public static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool FindClose(IntPtr hFindFile); } public class FileSearcher { private static uint convertToUInt(int value) { return BitConverter.ToUInt32( BitConverter.GetBytes(value), 0 ); } private static long convertToLong(int value) { return (long)(convertToUInt(value) << 32); } public static long[] MeasureItem(string path, bool recurse, long[] itemData) { if (itemData == null) { itemData = new long[]{ 0, 0, 0 }; } string searchPath; if (path.StartsWith(@"\\")) { searchPath = String.Format(@"\\?\UNC\{0}\*", path.Substring(2)); } else { searchPath = String.Format(@"\\?\{0}\*", path); } WIN32_FIND_DATA findData = new WIN32_FIND_DATA(); IntPtr findHandle = UnsafeNativeMethods.FindFirstFileExW(searchPath, 1, out findData, 0, IntPtr.Zero, 0); do { if (findData.dwFileAttributes.HasFlag(FileAttributes.Directory)) { if (recurse && findData.cFileName != "." && findData.cFileName != "..") { itemData[2]++; itemData = MeasureItem( Path.Combine(path, findData.cFileName), recurse, itemData ); } } else { itemData[0] += convertToLong(findData.nFileSizeHigh) + (long)convertToUInt(findData.nFileSizeLow); itemData[1]++; } } while (UnsafeNativeMethods.FindNextFile(findHandle, out findData)); UnsafeNativeMethods.FindClose(findHandle); return itemData; } } } ' } $power = ('B', 'KB', 'MB', 'GB', 'TB').IndexOf($Unit.ToUpper()) $denominator = [Math]::Pow(1024, $power) } process { $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path).TrimEnd('\') $itemData = [SC.IO.FileSearcher]::MeasureItem($Path, $true, $null) if ($ValueOnly) { [Math]::Round(($itemData[0] / $denominator), $Digits) } else { [PSCustomObject]@{ Path = $Path Size = [Math]::Round(($itemData[0] / $denominator), $Digits) FileCount = $itemData[1] DirectoryCount = $itemData[2] } } } } Function Set-CMDrive { <# .SYNOPSIS Import ConfigMgr module, create ConfigrMgr PS drive and set location to it. .DESCRIPTION Set current working directory to site code for access to ConfigMgr cmdlets. Some validation is in place to verify the site code marrys up to be of $Server. Called by main body. #> Param( [string]$SiteCode, [string]$Server, [string]$Path ) # Import the ConfigurationManager.psd1 module If((Get-Module ConfigurationManager) -eq $null) { try { Import-Module ("{0}\..\ConfigurationManager.psd1" -f $ENV:SMS_ADMIN_UI_PATH) } catch { $Message = "Failed to import Configuration Manager module" Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation" throw $Message } } try { # Connect to the site's drive if it is not already present If((Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue) -eq $null) { New-PSDrive -Name $SiteCode -PSProvider CMSite -Root $Server -ErrorAction Stop | Out-Null } # Set the current location to be the site code. Set-Location ("{0}:\" -f $SiteCode) -ErrorAction Stop # Verify given sitecode If((Get-CMSite -SiteCode $SiteCode | Select-Object -ExpandProperty SiteCode) -ne $SiteCode) { throw } } catch { If((Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue) -ne $null) { Set-Location $Path Remove-PSDrive -Name $SiteCode -Force } $Message = "Failed to create New-PSDrive with site code `"{0}`" and server `"{1}`"" -f $SiteCode, $Server Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation" throw $Message } } Write-CMLogEntry -Value "Starting" -Severity 1 -Component "Initilisation" -WriteHost # To calculate total runtime $StartTime = Get-Date # Write all parameters passed to script to log ForEach($item in $PSBoundParameters.GetEnumerator()) { Write-CMLogEntry -Value ("- {0}: {1}" -f $item.Key, $item.Value) -Severity 1 -Component "Initilisation" } # If user has given local path for $SourcesLocation, need to ensure we don't produce false positives where a similar folder structure exists on the remote machine and site server. e.g. packages let you specify local path on site server If ((([System.Uri]$SourcesLocation).IsUnc -eq $false) -And ($env:COMPUTERNAME -ne $SiteServer)) { $Message = "Won't be able to determine unused folders with given local path while running remotely from site server, quitting" Write-CMLogEntry -Value $Message -Severity 2 -Component "Initilisation" -WriteHost throw $Message } # Import PSWriteHtml module report if -HtmlReport is present If ($HtmlReport.IsPresent -eq $true) { try { Import-Module PSWriteHTML -ErrorAction Stop } catch { $Message = "Unable to import PSWriteHtml module: {0}" -f $error[0].Exception.Message Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation" throw $Message } [version]$moduleVersion = (Get-Module PSWriteHTML | Sort-Object Version -Descending | Select-Object -ExpandProperty Version)[0] If($moduleVersion -lt [version]"0.0.44") { $Message = "PSWriteHtml version is too old ({0}). Requires 0.0.44+." -f $moduleVersion.ToString() Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation" throw $Message } } # Build the $Commands array ready for Get-CMContent switch ($true) { ($Packages.IsPresent -eq $true) { [array]$Commands += "Get-CMPackage" } ($Applications.IsPresent -eq $true) { [array]$Commands += "Get-CMApplication" } ($Drivers.IsPresent -eq $true) { [array]$Commands += "Get-CMDriver" } ($DriverPackages.IsPresent -eq $true) { [array]$Commands += "Get-CMDriverPackage" } ($OSImages.IsPresent -eq $true) { [array]$Commands += "Get-CMOperatingSystemImage" } ($OSUpgradeImages.IsPresent -eq $true) { [array]$Commands += "Get-CMOperatingSystemInstaller" } ($BootImages.IsPresent -eq $true) { [array]$Commands += "Get-CMBootImage" } ($DeploymentPackages.IsPresent -eq $true) { [array]$Commands += "Get-CMSoftwareUpdateDeploymentPackage" } default { [array]$Commands = "Get-CMPackage", "Get-CMApplication", "Get-CMDriver", "Get-CMDriverPackage", "Get-CMOperatingSystemImage", "Get-CMOperatingSystemInstaller", "Get-CMBootImage", "Get-CMSoftwareUpdateDeploymentPackage" } } # Get NetBIOS of given $SiteServer parameter so it's similar format as $env:COMPUTERNAME used in body during folder/content object for loop # And also for value pair in each content objects .AllPaths property (hashtable) If ($SiteServer -as [IPAddress]) { $FQDN = [System.Net.Dns]::GetHostEntry($SiteServer) | Select-Object -ExpandProperty HostName } Else { $FQDN = [System.Net.Dns]::GetHostByName($SiteServer) | Select-Object -ExpandProperty HostName } $SiteServer = $FQDN.Split(".")[0] # Gather folders Write-CMLogEntry -Value ("Gathering folders: {0}" -f $SourcesLocation) -Severity 1 -Component "GatherFolders" -WriteHost If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 0 -Status ("Gathering all folders at: {0}" -f $SourcesLocation) } $AllFolders = Get-AllFolders -Path $SourcesLocation -AltFolderSearch $AltFolderSearch.IsPresent Write-CMLogEntry -Value ("Number of gathered folders: {0}" -f $AllFolders.count) -Severity 1 -Component "GatherFolders" -WriteHost # Gather content objects $OriginalPath = Get-Location | Select-Object -ExpandProperty Path Set-CMDrive -SiteCode $SiteCode -Server $SiteServer -Path $OriginalPath Write-CMLogEntry -Value ("Gathering content objects: {0}" -f ($Commands -replace "Get-CM" -join ", ")) -Severity 1 -Component "GatherContentObjects" -WriteHost If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 33 -Status ("Gathering CM content objects: {0}" -f ($Commands -replace "Get-CM" -join ", ")) } $AllContentObjects = Get-CMContent -Commands $Commands -SiteServer $SiteServer -SiteCode $SiteCode Write-CMLogEntry -Value ("Number of gathered content objects: {0}" -f $AllContentObjects.count) -Severity 1 -Component "GatherContentObjects" -WriteHost Set-Location $OriginalPath $AllFolders | ForEach-Object -Begin { If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 66 -Status "Determining unused folders" } Write-CMLogEntry -Value ("Determining unused folders, using {0} threads" -f $Threads) -Severity 1 -Component "Processing" -WriteHost # Make Test-FileSystemAccess function available to all runspaces $Definition = Get-Content Function:\Test-FileSystemAccess -ErrorAction Stop $SessionStateFunction = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList 'Test-FileSystemAccess', $Definition $initialSessionState = [InitialSessionState]::CreateDefault() $InitialSessionState.Commands.Add($SessionStateFunction) # Create runspace pool, initialise the results array and script block it'll churn through $RSPool = [RunspaceFactory]::CreateRunspacePool(1, $Threads, $InitialSessionState, $Host) $RSPool.ApartmentState = "MTA" $RSPool.Open() [System.Collections.Generic.List[Object]]$RSResults = @() $RSScriptBlock = { Param ( [string]$RSFolder, [System.Collections.Generic.List[Object]]$RSAllContentObjects ) # Initialise the essentials [System.Collections.Generic.List[String]]$UsedBy = @() $IntermediatePath = $false $NotUsed = $false switch ([int](Test-FileSystemAccess -Path $RSFolder -Rights Read)) { 5 { $UsedBy.Add("Access denied") } 740 { $UsedBy.Add("Access denied (elevation required)") } } # Still continue anyway, despite access denied, because we can still determine if it's an exact match or intermediate path of a content object # Filtered to exclude SourcePathFlag 3 so we can exclude false positives # e.g. if content objects uses \\server\share\folder1\folder2 and $SourcesLocation is \\server\share\folder1 # but SourcePathFlag is 3, this could report \\server\share\folder1 as intermediate where it may not be # Plus, no point iterating over them if we already know that the SourcePath isn't resolvable ForEach ($ContentObject in ($RSAllContentObjects.Where( {$_.SourcePathFlag -ne 3} ))) { switch($true) { ([string]::IsNullOrEmpty($ContentObject.SourcePath) -eq $true) { # Content object source path is empty, no point continuing break } ((([System.Uri]$SourcesLocation).IsUnc -eq $false) -And ($ContentObject.AllPaths.$RSFolder -eq $env:COMPUTERNAME)) { # Content object source path is on local file system to the site server $UsedBy.Add($ContentObject.Name) break } (($ContentObject.AllPaths.Keys -contains $RSFolder) -eq $true) { # By default the ContainsKey method ignores case # A match has been found within the AllPaths property of the content object $UsedBy.Add($ContentObject.Name) break } (($ContentObject.AllPaths.Keys -match [Regex]::Escape($RSFolder)).Count -gt 0) { # If any of the content object paths start with $RSFolder $IntermediatePath = $true break } ($ContentObject.AllPaths.Keys.Where{$RSFolder.StartsWith($_, "CurrentCultureIgnoreCase")}.Count -gt 0) { # If $RSFolder starts wtih any of the content object paths $IntermediatePath = $true break } default { # Folder isn't known to any content objects $NotUsed = $true } } } switch ($true) { ($UsedBy.count -gt 0) { $UsedBy = $UsedBy -join ", " break } ($IntermediatePath -eq $true) { $UsedBy = "An intermediate folder (sub or parent folder)" break } ($NotUsed -eq $true) { $UsedBy = "Not used" break } } [PSCustomObject]@{ Folder = $RSFolder UsedBy = $UsedBy -join ", " } } If ($NoProgress.IsPresent -eq $false) { If ($AllFolders.count -gt 150) { [Int32]$FolderInterval = $AllFolders.count * 0.01 } Else { $FolderInterval = 2 } } Write-CMLogEntry -Value "Adding jobs to queue" -Severity 1 -Component "Processing" -WriteHost } -Process { $Folder = $_ If ($NoProgress.IsPresent -eq $false) { If (($AllFolders.IndexOf($Folder) % $FolderInterval) -eq 0) { [Int32]$Percentage = ($AllFolders.IndexOf($Folder) / $AllFolders.count * 100) Write-Progress -Id 2 -Activity "Adding jobs to queue" -PercentComplete $Percentage -Status ("{0}% complete" -f $Percentage) -ParentId 1 } } $Runspace = [PowerShell]::Create() $null = $Runspace.AddScript($RSScriptBlock) $null = $Runspace.AddArgument($Folder) $null = $Runspace.AddArgument($AllContentObjects) $Runspace.Runspacepool = $RSPool $RSResults.Add( [PSCustomObject]@{ Pipe = $Runspace; Status = $Runspace.BeginInvoke() } ) } -End { Write-CMLogEntry -Value "Waiting for jobs to complete" -Severity 1 -Component "Processing" -WriteHost [System.Collections.Generic.List[Object]]$Result = @() # Process runspaces, wait for their results and clean up when complete $TotalRunspaces = $RSResults | Measure-Object | Select-Object -ExpandProperty Count while ($RSResults.Status -ne $null) { If ($NoProgress.IsPresent -eq $false) { $TotalNotComplete = $RSResults.Where( { $_.Status -eq $null } ) | Measure-Object | Select-Object -ExpandProperty Count Write-Progress -Id 2 -Activity "Evaluating folders" -Status ("{0} folders remaining" -f ($TotalRunspaces-$TotalNotComplete)) -PercentComplete ($TotalNotComplete/$TotalRunspaces * 100) -ParentId 1 } $Completed = $RSResults.Where( { $_.Status.IsCompleted -eq $true } ) ForEach ($item in $Completed) { # Reference index 0 so we can grab the PSCustomobject inside the PSDataCollection object $Result.Add(($item.Pipe.EndInvoke($item.Status)[0])) $item.Pipe.Dispose() $item.Status = $null } Start-Sleep -Seconds 2 } # Clean up runspace pool $RSPool.Dispose() Write-CMLogEntry -Value "Done determining unused folders" -Severity 1 -Component "Processing" -WriteHost # Update Write-Progress If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Evaluating folders" -Completed -ParentId 1 } If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 100 -Status "Finishing up" } Write-CMLogEntry -Value "Calculating used disk space by unused folders" -Severity 1 -Component "Exit" -WriteHost If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Calculating used disk space by unused folders" -PercentComplete 0 -ParentId 1 } # Calculate total MB used for each "Not used" folder # This is wasteful if -HtmlReport is not specified, but I really wanted $SummaryNotUsedFolders total in end result stats $NotUsedFolders = $Result.Where( { $_.UsedBy -eq "Not used" } ) # Calculate total MB used on size unused by ConfigMgr If ($NotUsedFolders.count -eq 0) { # PSCustomObject created so that when $NotUsedFolders is blank, PSWriteHtml won't print warnings because of missing properties when trying to create merge headers $SummaryNotUsedFolders = [PSCustomObject]@{ Path = 0 Size = 0 FileCount = 0 DirectoryCount = 0 } $SummaryNotUsedFoldersMB,$SummaryNotUsedFoldersFileCount,$SummaryNotUsedFoldersDirectoryCount = 0,0,0 } Else { $SummaryNotUsedFolders = $NotUsedFolders | Sort-Object Folder | ForEach-Object { $current = $_ If (($previous) -And ($current.Folder.StartsWith($previous.Folder))) { # Do nothing } Else { $previous = $current $current.Folder | Measure-ChildItem -Unit MB -Digits 2 } } Write-CMLogEntry -Value "Done calculating used disk space by unused folders" -Severity 1 -Component "Exit" -WriteHost $SummaryNotUsedFoldersMB = [Math]::Round(($SummaryNotUsedFolders | Measure-Object Size -Sum | Select-Object -ExpandProperty Sum), 2) $SummaryNotUsedFoldersFileCount = $SummaryNotUsedFolders | Measure-Object FileCount -Sum | Select-Object -ExpandProperty Sum $SummaryNotUsedFoldersDirectoryCount = $SummaryNotUsedFolders | Measure-Object DirectoryCount -Sum | Select-Object -ExpandProperty Sum } # Write $Result to log file # I know Write-CMLogEntry has Enabled parameter but having it here too just makes sense - to save the gazillion of loops for something that may be disabled anyway # May consider deleting this section, enough about the result is written to file If ($Log.IsPresent -eq $true) { $Message = "Writing result to log file" If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -PercentComplete 25 -ParentId 1 } Write-CMLogEntry -Value $Message -Severity 1 -Component "Processing" -WriteHost ForEach ($item in $Result) { switch -regex ($item.UsedBy) { "Access denied" { $Severity = 2 } default { $Severity = 1 } } Write-CMLogEntry -Value ($item.Folder + ": " + $item.UsedBy) -Severity $Severity -Component "Processing" } } # Export $Result to file If ($ExportReturnObject.IsPresent -eq $true) { try { Write-CMLogEntry -Value "Exporting PowerShell return object" -Severity 1 -Component "Exit" -WriteHost If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Exporting PowerShell return object" -PercentComplete 50 -ParentId 1 } Export-Clixml -LiteralPath (($PSCommandPath | Split-Path -Parent) + "\" + ($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + "_result.xml") -InputObject $Result Write-CMLogEntry -Value "Done exporting PowerShell return object" -Severity 1 -Component "Exit" -WriteHost } catch { Write-CMLogEntry -Value ("Failed to export PowerShell object: {0}" -f $error[0].Exception.Message) -Severity 3 -Component "Exit" -WriteHost } } # Export $AllContentObjects to file If ($ExportCMContentObjects.IsPresent -eq $true) { try { Write-CMLogEntry -Value "Exporting PowerShell ConfigMgr content objects object" -Severity 1 -Component "Exit" -WriteHost If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Exporting PowerShell ConfigMgr content objects object" -PercentComplete 75 -ParentId 1 } Export-Clixml -LiteralPath (($PSCommandPath | Split-Path -Parent) + "\" + ($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + "_cmobjects.xml") -InputObject $AllContentObjects Write-CMLogEntry -Value "Done exporting PowerShell ConfigMgr content objects object" -Severity 1 -Component "Exit" -WriteHost } catch { Write-CMLogEntry -Value ("Failed to export PowerShell object: {0}" -f $error[0].Exception.Message) -Severity 3 -Component "Exit" -WriteHost } } # Write $Result to HTML using PSWriteHTML If ($HtmlReport.IsPresent -eq $true) { try { Write-CMLogEntry -Value "Creating HTML report" -Severity 1 -Component "Exit" -WriteHost If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Creating HTML report" -PercentComplete 100 -ParentId 1 } New-HTML -TitleText ("Get-CMUnusedSources - {0}" -f $JobId) -UseCssLinks:$true -UseJavaScriptLinks:$true -FilePath (($PSCommandPath | Split-Path -Parent) + "\" + ($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + ".html") -ShowHTML { New-HTMLTabOptions -SlimTabs $Title = "All folders" New-HTMLTab -Name $Title { New-HTMLContent -HeaderText $Title { New-HTMLPanel { New-HTMLTable -DataTable $Result { New-HTMLTableCondition -Name "UsedBy" -Type string -Operator contains -Value "Access denied" -BackgroundColor Orange -Row } -PreContent { ("<span style='font-size: 1.2em; margin-left: 1em;'>All of the folders under `"{0}`" and their UsedBy status, same as what's returned by the script.</span>" -f $SourcesLocation) } } } } $Title = "Summary of not used folders" New-HTMLTab -Name $Title { New-HTMLContent -HeaderText $Title { New-HTMLPanel { New-HTMLTable -DataTable ($SummaryNotUsedFolders | Select-Object Path, @{Label="Size (MB)"; Expression={$_.Size}}, FileCount, DirectoryCount) { New-HTMLTableHeader -Names "Size (MB)" -Title ("{0}MB" -f $SummaryNotUsedFoldersMB) -Color White -FontWeight Bold -Alignment left -BackGroundColor LimeGreen New-HTMLTableHeader -Names "FileCount" -Title $SummaryNotUsedFoldersFileCount -Color White -FontWeight Bold -Alignment left -BackGroundColor LimeGreen New-HTMLTableHeader -Names "DirectoryCount" -Title $SummaryNotUsedFoldersDirectoryCount -Color White -FontWeight Bold -Alignment left -BackGroundColor LimeGreen New-HTMLTableHeader -Names "Path" -Title " " -ColumnCount 1 -AddRow New-HTMLTableHeader -Names "Size (MB)", "FileCount", "DirectoryCount" -Title "Totals" -Color White -FontWeight Bold -Alignment center -BackGroundColor LimeGreen -AddRow -ColumnCount 3 } -PreContent { "<span style='font-size: 1.2em; margin-left: 1em;'>A list of folders that were determined not used under the given path by the searched content objects. It does not include child folders, only `"unique root folders`", so this produces an accurate measurement of capacity used.</span>" } } } } $Title = "All not used folders" New-HTMLTab -Name $Title { New-HTMLContent -HeaderText $Title { New-HTMLPanel { New-HTMLTable -DataTable $NotUsedFolders -PreContent { "<span style='font-size: 1.2em; margin-left: 1em;'>All folders that were determined not used under the given path by the searched content objects.</span>" } } } } $Title = "Content objects with invalid path" New-HTMLTab -Name $Title { New-HTMLContent -HeaderText $Title { New-HTMLPanel { New-HTMLTable -DataTable ($AllContentObjects.Where( { ($_.SourcePathFlag -eq 3) -And ([string]::IsNullOrEmpty($_.SourcePath) -eq $false) } ) | Select-Object * -ExcludeProperty SourcePathFlag,AllPaths) -PreContent { "<span style='font-size: 1.2em; margin-left: 1em;'>All content objects that have a source path which are not accessible from the computer that ran this script (`"{0}`").</span>" -f $env:COMPUTERNAME } } } } $Title = "All content objects" New-HTMLTab -Name $Title { New-HTMLContent -HeaderText $Title { New-HTMLPanel { New-HTMLTable -DataTable ($AllContentObjects | Select-Object ContentType, UniqueID, Name, SourcePath, SourcePathFlag) -PreContent { "<span style='font-size: 1.2em; margin-left: 1em;'>All searched ConfigMgr content objects.</span>" } } } } } If ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Creating HTML report" -Completed -ParentId 1 } Write-CMLogEntry -Value "Done creating HTML report" -Severity 1 -Component "Exit" -WriteHost } catch { Write-CMLogEntry -Value ("Failed to create HTML report: {0}" -f $error[0]) -Severity 3 -Component "Exit" -WriteHost } } # Stop clock for runtime $StopTime = (Get-Date) - $StartTime # Write summary to log Write-CMLogEntry -Value ("Content objects: {0}" -f $AllContentObjects.count) -Severity 1 -Component "Exit" -WriteHost Write-CMLogEntry -Value ("Folders at {0}: {1}" -f $SourcesLocation, $AllFolders.count) -Severity 1 -Component "Exit" -WriteHost Write-CMLogEntry -Value ("Folders where access denied: {0}" -f ($Result.Where( { $_.UsedBy -like "Access denied*" } ) | Measure-Object | Select-Object -ExpandProperty Count)) -Severity 1 -Component "Exit" -WriteHost Write-CMLogEntry -Value ("Folders unused: {0}" -f ($NotUsedFolders | Measure-Object | Select-Object -ExpandProperty Count)) -Severity 1 -Component "Exit" -WriteHost Write-CMLogEntry -Value ("Disk space in `"{0}`" not used by ConfigMgr content objects ({1}): {2} MB" -f $SourcesLocation, ($Commands -replace "Get-CM" -join ", "), $SummaryNotUsedFoldersMB) -Severity 1 -Component "Exit" -WriteHost Write-CMLogEntry -Value ("Runtime: {0}" -f $StopTime.ToString()) -Severity 1 -Component "Exit" -WriteHost Write-CMLogEntry -Value "Finished" -Severity 1 -Component "Exit" return $Result } |