Public/Start-ParallelExecution.ps1
function Start-ParallelExecution { <# .SYNOPSIS This script runs a list of commands or scripts stored in a csv and on all the indicated machines. It supports multiple forests using a xml file that stores multiple credentials, it supports copying a folder to destination machines (to copy required modules or other files) Everything is parallelized for fast execution. The result of the execution of the commands is stored on a xml for further analysis or can be redirected to a variable It uses Powershell remoting for remote execution and SMB for prerequisites copy .EXAMPLE -------------------------- EXAMPLE 1 -------------------------- Start-ParallelExecution -ComputerNameFile .\machines.txt -InputCommandFile .\commands.csv -OutputFile .\output.xml -verbose Description ----------- This command get the list of machines specified on the ComputerNameFile parameter, runs the commands specified in the InputCommandFile parameter and stores the results in the OutputFile parameter, it uses -verbose to get execution information ----------- .EXAMPLE -------------------------- EXAMPLE 2 -------------------------- Start-ParallelExecution -ComputerNameFile .\machines.txt -CredentialFile .\creds.xml -InputCommandFile .\commands.csv -OutputFile .\output.xml Description ----------- This command get the list of machines specified on the ComputerNameFile parameter, runs the commands specified in the InputCommandFile parameter, using stored credentials from previous executions and stores the results in the OutputFile parameter ----------- .EXAMPLE -------------------------- EXAMPLE 3 -------------------------- Start-ParallelExecution -ComputerNameFile .\machines.txt -CredentialFile .\creds.xml -InputCommandFile .\commands.csv -OutputFile .\output.xml -prerequisitesfolder .\prereq -ScriptFolder .\scripts Description ----------- This command get the list of machines specified on the ComputerNameFile parameter, copy the contents of the folder specified on the prerequisitesfolder parameter to all of them, runs in paralel the commands and scripts specified in the InputCommandFile parameter, using stored credentials from previous executions and scripts stored in the folder specified on the ScriptFolder parameter and stores the results in the OutputFile parameter ----------- .EXAMPLE -------------------------- EXAMPLE 4 -------------------------- Start-ParallelExecution -ComputerName machine1.contoso.com,machine2.contoso.com -CredentialFile .\creds.xml -InputCommandFile .\commands.csv -OutputFile .\output.xml -prerequisitesfolder .\prereq -ScriptFolder .\scripts Description ----------- This command get the list of machines specified on the ComputerName parameter copy the contents of the folder specified on the prerequisitesfolder parameter, runs the commands and scripts specified in the InputCommandFile parameter, using stored credentials from previous executions and scripts stored in the folder specified on the ScriptFolder parameter and stores the results in the OutputFile parameter ----------- .EXAMPLE -------------------------- EXAMPLE 5 -------------------------- Get-ADDomainController -filter * | select -expandproperty hostname | Start-ParallelExecution -CredentialFile .\creds.xml -InputCommandFile .\commands.csv -OutputFile .\output.xml -ScriptFolder .\scripts Description ----------- This command get the list of machines passed through the pipeline (in the example we get the list of all domain controllers in a domain and use that as input), runs the commands and scripts specified in the InputCommandFile parameter, using stored credentials from previous executions and scripts stored in the folder specified on the ScriptFolder parameter and stores the results in the OutputFile parameter ----------- .EXAMPLE -------------------------- EXAMPLE 6 -------------------------- $results = Start-ParallelExecution -CredentialFile .\creds.xml -ComputerName machine1.contoso.com,machine2.contoso.com -InputCommandFile .\commands.csv -ScriptFolder .\scripts Description ----------- This command gets a list of machines from the machinelist parameter, runs the commands and scripts specified in the InputCommandFile parameter, using stored credentials from previous executions and scripts stored in the folder specified on the ScriptFolder parameter and stores the results in the $results variable. ----------- .PARAMETER InputCommandFile This is the input file (# separated) with the commands or scripts to run on each machine, the format is as follows -------------------------- EXAMPLE CSV FILE -------------------------- propertyname#command#Script#Description testcommand #get-help#false#this executes get-help in the destination machines #####lines starting with "#" are ignored###### testscript#.\testscript.ps1#true#this executes testsript.ps1 in the destination machines ----------- .PARAMETER OutputFile this optional parameter is the output file in xml format with the results of the commands ran against all machines, it contains a hashtable of objects with one entry per machine, each entry contains one property per command/script executed with the result of the execution of that command .PARAMETER TimeoutInSeconds This parameter contains the timeout used for jobs (to finish jobs that are hung) .PARAMETER ComputerNameFile This parameter contains the file with the list of fqdn of machines to run against .PARAMETER machineslist This parameter contains the comma separated list of fqdn of machines to work against, can accept input from pipeline .PARAMETER CredentialFile This optional parameter contains a xml file with the saved credentials per domain, if specified and the file does not exist it will ask for credentials and create the creds file for later use, if not specified it will use local logged on user credentials .PARAMETER prerequisitesfolder This optional parameter contains the path to a folder to be copied to the same destination to all computers (to include modules and other prerequisites) .PARAMETER ScriptFolder This optional parameter contains the path to a folder that contains all scripts that will be called from the InputCommandFile file) .PARAMETER Command This optional parameter contains a single command to be executed in paralel against all machines instead of using a csv file .PARAMETER Script This optional parameter contains a single script name to be executed in paralel against all machines instead of using a csv file, it must be used with the scriptfoler command .PARAMETER Throttlecopy This optional parameter defines the number of simultaneous copy operations if a prerequisites folder is specified .PARAMETER ConfigurationName This optional parameter allows the usage of a custom JEA session by using the configurationname switch while creating the remote sessions #> [CmdletBinding(DefaultParameterSetName = 'PipelineSingle')] Param( [parameter(Mandatory = $True, ValueFromPipeline = $True, HelpMessage = "Enter the list of machines's fqdn.", ValueFromPipelineByPropertyName = $True, ParameterSetName = 'PipelineSingle')] [parameter(Mandatory = $True, ValueFromPipeline = $True, HelpMessage = "Enter the list of machines's fqdn.", ValueFromPipelineByPropertyName = $True, ParameterSetName = 'PipelineMulti')] [parameter(Mandatory = $True, ValueFromPipeline = $True, HelpMessage = "Enter the list of machines's fqdn.", ValueFromPipelineByPropertyName = $True, ParameterSetName = 'Pipelinescript')] [string[]] $ComputerName, [parameter(Mandatory = $true, HelpMessage = "Enter txt file with the lists of machines's fqdn.", ParameterSetName = 'ListSingle')] [Parameter(Mandatory = $true, HelpMessage = "Enter txt file with the lists of machines's fqdn.", ParameterSetName = 'ListMulti')] [Parameter(Mandatory = $true, HelpMessage = "Enter txt file with the lists of machines's fqdn.", ParameterSetName = 'Listscript')] [string] $ComputerNameFile, [Parameter(Mandatory = $true, ParameterSetName = 'PipelineMulti')] [Parameter(Mandatory = $true, ParameterSetName = 'ListMulti')] [string] $InputCommandFile, [Parameter(Mandatory = $true, ParameterSetName = 'ListSingle')] [Parameter(Mandatory = $true, ParameterSetName = 'PipelineSingle')] [string] $Command, [Parameter(Mandatory = $true, ParameterSetName = 'Listscript')] [Parameter(Mandatory = $true, ParameterSetName = 'Pipelinescript')] [string] $script, [string] $OutputFile, [string] $CredentialFile, [int] $TimeoutInSeconds = "900", [string] $PrerequisitesFolder = ".\prereq\", [string] $ScriptFolder = ".\scripts\", [string] $Throttlecopy = "5", [string] $ConfigurationName ) #We use an advanced function so if machines are passed through the pipeline, an array is created and then executed in paralel against all of them BEGIN { $pipelinearray = @(); } PROCESS { $pipelinearray += $_; } END { #This code fills the machinelist variable with what has passed (if so) through the pipeline if ($pipelinearray -ne $null) { $ComputerName = $pipelinearray } #initialize some objects that will be later on used $machines = @() $i = 0 $generalhashtable = @{} #Check that all needed files exist and break if not if ($PSBoundParameters.ContainsKey('prerequisitesfolder')) { try { $sourcepath = get-item $PrerequisitesFolder -ErrorAction stop } catch { Write-Error "prerequisites folder not found, closing" return } } if ($PSBoundParameters.ContainsKey('scriptsfolder')) { try { get-item $ScriptFolder -ErrorAction stop | Out-Null } catch { Write-Error "scripts folder not found, closing" return } } #if the machinesfile parameter has been specified we load the file, if not we already have an array of machines in the machineslist variable if ($PSCmdlet.ParameterSetName -like 'List*') { try { $ComputerName = Get-Content -Path $ComputerNameFile -ErrorAction Stop } catch { Write-Error "machines file not found, closing" return } } #we generate the commands list from the csv or directly from the single command parmeter if ($PSCmdlet.ParameterSetName -like '*Multi') { try { #For commands file we cleans commented ones (start with # so property name is empty) $commands = Import-Csv $InputCommandFile -Delimiter "#" -ErrorAction Stop | Where-Object {$_.PropertyName -ne ""} } catch { Write-Error "commands file not found, closing" return } } if ($PSCmdlet.ParameterSetName -like '*Single') { $commands = @() $commands += New-Object -TypeName psobject -Property @{ propertyname = 'results' command = $command Script = $false } } if ($PSCmdlet.ParameterSetName -like '*script') { $commands = @() $commands += New-Object -TypeName psobject -Property @{ propertyname = 'results' command = $script Script = $true } } #generates machines objects from the list, creates the domain list and get a valid credential per each one of the domains if cred file is specified and not exist $machines = Get-MachineObject $ComputerName $domains = $machines | Select-Object -ExpandProperty Domain | Sort-Object -Unique if ($PSBoundParameters.ContainsKey('CredentialFile')) { try { Get-Item $CredentialFile -ErrorAction stop | Out-Null $Domcreds = Get-DomainCredential -path $CredentialFile } catch { $Domcreds = Get-DomainCredential -Domain $Domains -path $CredentialFile } } #Cleanup stale pssessions and jobs from previous executions Get-PSSession | Where-Object Name -like 'ParallelExecution*' | Remove-PSSession Get-Job -Name Parallel*| stop-job get-job -Name Parallel* |Remove-Job #We iterate over the domains opening sessions in paralel for each domain (we can not further parelalize because of credentials#> Modified to support JEA so that we can specfy a custom ConfigurationName session name if ($PSBoundParameters.ContainsKey('ConfigurationName')) { if ($PSBoundParameters.ContainsKey('CredentialFile')) { foreach ($domain in $domains) { $machinesinthisdomain = $machines| Where-Object {$_.domain -eq $domain} [array]$countinthisdomain = $machinesinthisdomain write-verbose "opening sessions against $($countinthisdomain.Count) machine(s) in domain $domain" $machinesinthisdomain.hostname | ForEach-Object {New-PSSession -ComputerName $_ -Name "ParallelExecutionTo-$_" -Credential $Domcreds[$Domain] -ConfigurationName $ConfigurationName -ThrottleLimit 25} | Out-Null } } #if we use current logged on user credentials we can crete sessions in one step for all machines else { write-verbose "opening sessions against $($machines.Count) machine(s)" $machines.Hostname | ForEach-Object {New-PSSession -ComputerName $_ -Name "ParallelExecutionTo-$_" -ConfigurationName $ConfigurationName -ThrottleLimit 25} | Out-Null } } else { if ($PSBoundParameters.ContainsKey('CredentialFile')) { foreach ($domain in $domains) { $machinesinthisdomain = $machines| Where-Object {$_.domain -eq $domain} [array]$countinthisdomain = $machinesinthisdomain write-verbose "opening sessions against $($countinthisdomain.Count) machine(s) in domain $domain" $machinesinthisdomain.hostname | ForEach-Object {New-PSSession -ComputerName $_ -Name "ParallelExecutionTo-$_" -Credential $Domcreds[$Domain] -ThrottleLimit 25} | Out-Null } } #if we use current logged on user credentials we can crete sessions in one step for all machines else { write-verbose "opening sessions against $($machines.Count) machine(s)" $machines.Hostname | ForEach-Object {New-PSSession -ComputerName $_ -Name "ParallelExecutionTo-$_" -ThrottleLimit 25} | Out-Null } } #Generates the hashtable using the hostname as key and adding the hostname, Domain, and opened session as properties. foreach ($machine in $Machines) { $sessions = Get-PSSession | Where-Object Name -like 'ParallelExecution*' $session = $null $session = $sessions | Where-Object {$_.ComputerName -eq $machine.Hostname} if ($session) { $resultsobj = New-Object psobject $resultsobj | Add-Member -MemberType NoteProperty -Name Hostname -Value $machine.HostName -force $resultsobj | Add-Member -MemberType NoteProperty -Name DomainName -Value $machine.Domain -force $resultsobj | Add-Member -MemberType NoteProperty -Name Session -Value $session -force $generalhashtable.add($machine.hostname, $resultsobj) } else {write-error "unable to open a session against $($machine.HostName)"} } write-verbose "successfully opened a session against $($generalhashtable.keys.Count) hosts" #Copy the prerequesites to the destination machines if ($PSBoundParameters.ContainsKey('prerequisitesfolder')) { write-verbose "copying files" $job = $generalhashtable.keys| ForEach-Object { while (@(Get-Job -Name 'ParallelExecution*' | Where-Object State -eq Running).Count -ge $Throttlecopy) { $now = Get-Date foreach ($job in @(Get-Job -Name 'ParallelExecution*' | Where-Object State -eq Running)) { if ($now - $job.PSBeginTime -gt [TimeSpan]::Fromseconds($TimeoutInSeconds)) { Stop-Job $job } } Start-Sleep -sec 2 } $machine = $_ $generalobj = $($generalhashtable.$machine) $domain = $generalobj.domainname write-verbose "starting copy on $_" if ($PSBoundParameters.ContainsKey('CredentialFile')) { Start-Job -Name "ParallelExecution$_" -ScriptBlock { param ($cpn, $sourcepath, $PrerequisitesFolder, $Domcreds, $Domain) $short = $cpn.split(".")[0] + (Get-Random(1..100)) New-PSDrive -Name $short -PSProvider filesystem -Root "\\$cpn\c$" -Credential $Domcreds[$Domain] $destinationpath = $short + ":" + $sourcepath.PSParentPath.split("::")[3] #for this to work no other SMB connection (even an explorer one) has to be opened to the destination machine, TODO:Remove machines we can not copy prereqs to $copy = copy-Item -Recurse -Force -Path $PrerequisitesFolder -Destination $destinationpath -PassThru Remove-PSDrive -Name $short } -ArgumentList $_, $sourcepath, $PrerequisitesFolder, $Domcreds, $domain } else { Start-Job -Name "ParallelExecution$_" -ScriptBlock { param ($cpn, $sourcepath, $PrerequisitesFolder, $Domcreds, $Domain) $short = $cpn.split(".")[0] + (Get-Random(1..100)) New-PSDrive -Name $short -PSProvider filesystem -Root "\\$cpn\c$" $destinationpath = $short + ":" + $sourcepath.PSParentPath.split("::")[3] #for this to work no other SMB connection (even an explorer one) has to be opened to the destination machine, TODO:Remove machines we can not copy prereqs to $copy = copy-Item -Recurse -Force -Path $PrerequisitesFolder -Destination $destinationpath -PassThru Remove-PSDrive -Name $short write-verbose "copy finished on $cpn" } -ArgumentList $_, $sourcepath, $PrerequisitesFolder, $Domcreds, $domain } } [void] ($job | Wait-Job | Receive-Job) } #clean stale jobs Get-Job -Name "ParallelExecution*" | stop-job Get-Job -Name "ParallelExecution*"| remove-job #for each command for each one of the machines... foreach ($commandInfo in $commands) { $commandstarted = get-date #Start the execution of the jobs depending if it is a command or script what we need to run if (-not [System.Convert]::ToBoolean($commandInfo.script)) { write-verbose "Processing the command $($commandInfo.propertyname)" foreach ($machine in $generalhashtable.keys) { $i++ #converts the command to scriptblock and runs the command as a job $scriptblock = [scriptblock]::create($commandInfo.command) Invoke-Command -Session $generalhashtable.$machine.session -ScriptBlock $scriptblock -JobName "ParallelInvoke$i" -AsJob | Out-Null } } elseif ([System.Convert]::ToBoolean($commandInfo.script)) { write-verbose "Processing the script $($commandInfo.propertyname)" foreach ($machine in $generalhashtable.Keys) { $i++ $filepath = $null if ($ScriptFolder[-1] -ne "\") {$ScriptFolder = $ScriptFolder + "\"} $filepath = $ScriptFolder + $commandInfo.command Invoke-Command -Session $generalhashtable.$machine.session -FilePath $filepath -JobName "ParallelInvoke$i" -AsJob | Out-Null } } else { write-verbose "Command $($commandInfo.command) not executed because could not identify if command or script, value was $($commandInfo.script) please check commands file" } #We sleep for some time to let the jobs finish and increase the timer in each future loop pass [int]$sleeptimer = 5 While ((Get-Job -Name ParallelInvoke*).count -gT 0) { Start-Sleep -Seconds $sleeptimer $sleeptimer = $sleeptimer * 1.5 $jobs = Get-Job -Name ParallelInvoke* $now = Get-Date #We go over the list of jobs receiving results and deleting failed or hung jobs foreach ($job in $jobs) { $location = $job.location #on completed jobs, receive the result, store the results and remove the jobs if ($job.State -eq "Completed") { $result = $null $result = Receive-Job $job #Here we put in the machine object a new property with the result of the job $generalhashtable.$Location| Add-Member -MemberType NoteProperty -Name $commandInfo.propertyname -Value $result -force Remove-job $job } #handles hung jobs so that are removed after timeout elseif ($now - (Get-Job -Id $job.id).PSBeginTime -gt [TimeSpan]::FromSeconds($TimeoutInSeconds)) { $generalhashtable.$Location| Add-Member -MemberType NoteProperty -Name $commandInfo.propertyname -Value "JOBFAILED" -force stop-job $job write-verbose "The command failed due to timeout in $($job.Location)" remove-Job $job } #handles failed jobs elseif ($job.State -eq "Failed") { $location = $job.location $errormessage=$null $errormessage=($job | Receive-Job 2>&1) $generalhashtable.$Location| Add-Member -MemberType NoteProperty -Name $commandInfo.propertyname -Value $errormessage -force write-verbose "The command failed to execute in $($job.Location)" remove-Job $Job } } #writes how long this command has been executed and how long to timeout $date = get-date [int]$spanned = ($date - $commandstarted).TotalSeconds $left = $TimeoutInSeconds - $spanned $pending = (get-job -Name ParallelInvoke*).count write-verbose "$pending jobs pending completion, current command has been running for $spanned seconds, $left seconds left" } } #cleanup sessions write-verbose "cleaning sessions" Get-PSSession | Where-Object Name -like 'ParallelExecution*' | Remove-PSSession #removing session from atributes as it is useless for output foreach ($ght in $generalhashtable.GetEnumerator()) { $val = $ght.Value $val.PsObject.Members.Remove('Session') } #exporting to xml or to output, it seems we have encountered a bug here that gives unexpected output when exporting to xml if verbose is on #https://github.com/PowerShell/PowerShell/issues/1522 if ($PSBoundParameters.ContainsKey('OutputFile')) { try { write-verbose "Data exporting to xml file" $generalhashtable | Export-Clixml -Path $OutputFile } catch { Write-error "unable to write to output xml file" break } } else { return $generalhashtable } } } |