In honor of today, 2/22/2022, I thought I'd share a PowerShell function that allows you to copy files to multiple destinations. If you look at help for Copy-Item, which you should, you'll see that the Destination parameter does not take an array. That's ok. I can fix that. However, I have a disclaimer that you should consider this a proof-of-concept and not production-ready code.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
The first step was to create a copy of the Copy-Item cmdlet. I used the Copy-Command function from the PSScriptTools module, which you can install from the PowerShell Gallery. I used the function to create a "wrapper" function based on Copy-Item. Copy-Command copies the original parameters. I edited the Destination parameter to accept an array of locations.
[Parameter(
Mandatory,
Position = 1,
ValueFromPipelineByPropertyName,
HelpMessage = "Specify a file system destination."
)]
[ValidateScript({Test-Path $_})]
[string[]]$Destination,
Because the parameters of my new function are identical to Copy-Item, I can use PSBoundParameters to build a Copy-Item command. However, I need to remove the Destination parameter since Copy-Item isn't written to support an array.
[void]($PSBoundParameters.Remove("Destination"))
This line goes in the Process scriptblock because I defined Destination to take pipeline input by value.
The concept behind my new function is quite simple. Run a Copy-Item command foreach destination. But, if I'm copying many files, I want this to run quickly and more or less in parallel. That's when I realized I could use Start-ThreadJob. If you don't have the ThreadJob module installed, you can get it from the PowerShell Gallery. Unlike Start-Job, which spins up a new runspace, Start-Threadjob creates a job in a separate thread within the local process. This tends to be faster.
The tricky part was figuring out how to pass parameters. I could have splatted parameter values.
start-threadjob {param($splat,$location) Copy-item @splat -destination $location} -arguments @($psboundparameters,$location)
And I will keep this concept in mind for future projects. Instead, I decided to recreate the parameters from PSBoundParameters. If I can create a command string, I can turn that into a scriptblock, which I can then pass to Start-ThreadJob.
$PSBoundParameters.GetEnumerator() | ForEach-Object -Begin { $p = "Copy-Item" } -Process {
if ($_.value -eq $True) {
$p += " -$($_.key)"
}
else {
$p += " -$($_.key) $($_.value)"
}
} -End {
$p += " -destination $location"
}
In the ForEach-Object Begin parameter, I'm initializing a string with the Copy-Item command. Then for each enumerated PSBoundParameter, I reconstruct the parameter name and value. The "gotcha" is handling switches like -Recurse or -WhatIf. So if the value is equal to True, I'll assume the key is a switch parameter. Usually, I abstain from explicit boolean comparisons in an If statement, but in this case, I am checking the value of $_.value and not merely if it exists. When I'm finished, $p will look like "Copy-Item -Verbose -WhatIf -Path C:\Scripts\WMIDiag.exe -destination d:\temp". I can turn that into a scriptblock and run it as a ThreadJob.
$sb = [scriptblock]::Create($p)
#WhatIf code ommitted here
$j = Start-ThreadJob $sb
The approach I am taking, depending on how you are running the copy command, could create a thread job for every file to be copied. There is a bit of overhead, so this may not perform well for many small files, but I'd take it when copying large files.
If I trust that I'll never make a mistake, I could stop. But, I might want to use -Passthru from Copy-Item. Or need to see if there are any errors. This means I will have to get the job results. In my functions' Begin block, I'll initialize a generic list.
$jobs = [System.Collections.Generic.List[object]]::new()
I'll add each job to the list created in the Process script block.
$jobs.add($j)
In the End scriptblock, assuming there are jobs, I'll wait for the last of them to complete, get the results, and remove the job.
If ($jobs) {
Write-Verbose "$((Get-Date).TimeOfDay) [END ] Processing $($jobs.count) thread jobs"
$jobs | Wait-Job | Receive-Job
$jobs | Remove-Job
}
Now, I can copy files to 2 (or more) directories with one command!
Want to try?
#requires -version 5.1
#requires -module ThreadJob
Function Copy-ItemToMultiDestination {
[CmdletBinding(
SupportsShouldProcess,
ConfirmImpact = 'Medium'
)]
[alias("cp2")]
Param(
[Parameter(
Mandatory,
Position = 0,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = "Specify a file system source path."
)]
[ValidateScript({ Test-Path $_ })]
[string[]]$Path,
[Parameter(
Mandatory,
Position = 1,
ValueFromPipelineByPropertyName,
HelpMessage = "Specify a file system destination."
)]
[ValidateScript({ Test-Path $_ })]
[string[]]$Destination,
[switch]$Container,
[switch]$Force,
[string]$Filter,
[string[]]$Include,
[string[]]$Exclude,
[switch]$Recurse,
[switch]$PassThru,
[Parameter(ValueFromPipelineByPropertyName)]
[pscredential]
[System.Management.Automation.CredentialAttribute()]$Credential
)
Begin {
Write-Verbose "$((Get-Date).TimeOfDay) [BEGIN ] Starting $($MyInvocation.Mycommand)"
#initialize a list for thread jobs
$jobs = [System.Collections.Generic.List[object]]::new()
} #begin
Process {
[void]($PSBoundParameters.Remove("Destination"))
foreach ($location in $Destination) {
Write-Verbose "$((Get-Date).TimeOfDay) [PROCESS] Creating copy job"
$PSBoundParameters.GetEnumerator() | ForEach-Object -Begin { $p = "Copy-Item" } -Process {
if ($_.value -eq $True) {
$p += " -$($_.key)"
}
else {
$p += " -$($_.key) $($_.value)"
}
} -End {
$p += " -destination $location"
}
Write-Verbose "$((Get-Date).TimeOfDay) [PROCESS] $p"
$sb = [scriptblock]::Create($p)
#Start-ThreadJob doesn't support -whatif
if ($PSCmdlet.ShouldProcess($p)) {
$j = Start-ThreadJob $sb
#add each job to the list
$jobs.add($j)
}
} #foreach location
} #process
End {
If ($jobs) {
Write-Verbose "$((Get-Date).TimeOfDay) [END ] Processing $($jobs.count) thread jobs"
$jobs | Wait-Job | Receive-Job
$jobs | Remove-Job
}
Write-Verbose "$((Get-Date).TimeOfDay) [END ] Ending $($MyInvocation.Mycommand)"
} #end
} #end function Copy-ItemToMultiDestination
Remember, this is a proof of concept. Although my mind is already whirling on other commands I could "update" using some of these techniques. Have fun.
2 thoughts on “Copy to Multiple Destinations with PowerShell”
Comments are closed.