Oct 12 2022 03:30 PM
My company is separating our networks and I'm creating a 'jumpbox' to sit between the networks and access folder shares on each side to transfer files from one side to the other. I need a simple script that would move files from the 'send' folder on one side to the 'receive' folder on the other. And I need it to delete files after they've been there for 7 days. If it was just syncing the folders, I'd use Robocopy & then something else to cleanup the files.
I've just started learning Powershell & reading the "Learn Powershell in a month of lunches" book. I haven't gotten far enough in to start writing this on my own. And my company doesn't have the time to wait for me to learn. I need this ready to goWhere can I go to ask for help in getting a simple script written to do this task?
Oct 13 2022 07:15 AM - edited Oct 13 2022 07:39 AM
It's an interesting scenario where the more you think about it, the more hidden complexities come to light. And this is mostly coming from where you stated you want to "send" and "move" the items rather than copy them while leaving them in situ within the source directory.
Here's a script that despite its length is actually fairly basic given how many things can go wrong with transient network errors, misconfigurations, etc.
Generally speaking, its error handling is primarily concerned with non-transient errors like access denied exceptions.
There's limited statistical output but you can jump in there and add your own Write-Verbose statements if you like.
Anyhow, because of your "send" and "move" statements, I'm treating the source directory like a queue rather than an authoritative source - which reflects what you said about RoboCopy.
Basically, the files are read from the queue and are copied, and if successfully copied, removed from the source.
One of the subtle considerations is of the timestamps. On the one hand, you need to make use of one of them on the destination when assessing which items are older than the retention period, but on the other, the dates don't actually change in numerous scenarios, meaning you have to explicitly update whichever timestamp you intend to assess during the retention deletion assessment stage.
I've gone with LastAccessTime as LastWriteTime is valuable for other reasons (i.e. for when actual file content changes rather than the file just being moved around), and CreationTime serves a fairly well-defined purpose.
This has its own "gotchas" though, notably that if another process gains a lock on those files or directories, you can't change the times, so nothing's perfect on this front either.
Still, here's the script and some sample output. Note that you can optionally specify the retention period (in days) and force file copying where the script would otherwise default to not copying a file due to the LastWriteTime and file length having not changed from what's present on the version already held in the destination.
I put in this basic differential logic since I don't know if you're copying small .txt files measured in kilobytes, CAD files in the low gigabytes or extensive video media in the dozens of gigabytes - it's up to you whether you leverage that or not.
[cmdletbinding()]
Param(
[parameter(Mandatory=$true)][ValidateNotNullOrEmpty()][string] $Source,
[parameter(Mandatory=$true)][ValidateNotNullOrEmpty()][string] $Destination,
[parameter()][int] $RetentionPeriod = 7,
[parameter()][switch] $Force
)
#region Function definitions.
function Copy-FileSystemObject([string] $Source, [string]$Destination, [switch] $Force, [ref] $Statistics)
{
try
{
Get-ChildItem -Path "$Source\*" -ErrorAction:Stop |
ForEach-Object {
$SourceItem = $_;
$SourcePath = $SourceItem.FullName;
$DestinationPath = Join-Path -Path $Destination -ChildPath ($SourceItem.Name);
# If this item is a directory, we need to copy it, update the relevant timestamps then call this same function recursively.
if ($SourceItem.Attributes.HasFlag([System.IO.FileAttributes]::Directory))
{
try
{
Copy-Item -Path $SourcePath -Destination $Destination -Force -ErrorAction:Stop;
#region Update timestamps on the destination file system object.
$DestinationItem = Get-Item -Path $DestinationPath -ErrorAction:Stop;
try
{
# We're going to buffer the timestamp updates as they can fail for non-critical reasons like the file system object being in use by another process.
if ($DestinationItem.Attributes.HasFlag([System.IO.FileAttributes]::ReadOnly))
{
$DestinationItem.Attributes = $DestinationItem.Attributes -bxor [System.IO.FileAttributes]::ReadOnly;
$DestinationItem.LastAccessTime = [datetime]::Now;
$DestinationItem.LastAccessTimeUtc = $DestinationItem.LastAccessTime.ToUniversalTime();
$DestinationItem.Attributes = $DestinationItem.Attributes -bor [System.IO.FileAttributes]::ReadOnly;
}
else
{
$DestinationItem.LastAccessTime = [datetime]::Now;
$DestinationItem.LastAccessTimeUtc = $DestinationItem.LastAccessTime.ToUniversalTime();
}
}
catch
{
# Do nothing, which suppresses any exceptions thrown in the try block.
}
#endregion
# Time to call this function recursively.
Copy-FileSystemObject -Source $SourcePath -Destination $DestinationPath -Statistics $Statistics;
# Try an explicit delete of the source directory, which we expect to fail (and will supress) if all the children weren't successfully moved during the recursive call.
Remove-Item -Path $SourcePath -ErrorAction:SilentlyContinue;
$Statistics.Value.Copied++;
}
catch
{
# Nothing to do here. We'll bubble this error up to the higher level catch for processing.
throw;
}
}
else
{
# If we're in this block, we're dealing with a file, not a directory.
try
{
#region Fetch the destination object if it already exists.
try
{
# This destination file check gets its own try..catch block since the file not existing isn't a reason to halt processing.
$DestinationItem = Get-Item -Path $DestinationPath -ErrorAction:Stop;
}
catch [System.Management.Automation.ItemNotFoundException]
{
# This is the "file not found" block, which isn't fatal.
$DestinationItem = $null;
}
catch
{
# Consider anything else fatal and halt.
throw;
}
#endregion
#region Copy the item, if appropriate.
try
{
# See if we need to actually copy the item.
if (($Force.IsPresent) -or ($null -eq $DestinationItem) -or ($DestinationItem.Length -ne $SourceItem.Length) -or ($DestinationItem.LastWriteTime -ne $SourceItem.LastWriteTime))
{
Copy-Item -Path $SourcePath -Destination $DestinationPath -Force -ErrorAction:Stop;
$Statistics.Value.Copied++;
}
else
{
$Statistics.Value.Skipped++;
}
#region Update timestamps on the destination file system object.
$DestinationItem = Get-Item -Path $DestinationPath -ErrorAction:Stop;
try
{
# We're going to buffer the timestamp updates as they can fail for non-critical reasons like the file system object being in use by another process.
if ($DestinationItem.Attributes.HasFlag([System.IO.FileAttributes]::ReadOnly))
{
$DestinationItem.Attributes = $DestinationItem.Attributes -bxor [System.IO.FileAttributes]::ReadOnly;
$DestinationItem.LastAccessTime = [datetime]::Now;
$DestinationItem.LastAccessTimeUtc = $DestinationItem.LastAccessTime.ToUniversalTime();
$DestinationItem.Attributes = $DestinationItem.Attributes -bor [System.IO.FileAttributes]::ReadOnly;
}
else
{
$DestinationItem.LastAccessTime = [datetime]::Now;
$DestinationItem.LastAccessTimeUtc = $DestinationItem.LastAccessTime.ToUniversalTime();
}
}
catch
{
# Do nothing, which suppresses any exceptions thrown in the try block.
}
#endregion
# Try an explicit delete of the source file.
Remove-Item -Path $SourcePath -ErrorAction:Stop;
}
catch
{
# Nothing to do here. We'll bubble this error up to the higher level catch for processing.
throw;
}
#endregion
}
catch [System.UnauthorizedAccessException]
{
# Access denied is a non-transient error and therefore fatal. We need to bail in this case.
# While it's possible that someone has fiddled with the security on a specific file or directory, we're going to assume that isn't the case and the access denied therefore pertains to the full structure courtesy of running under the wrong security principal, etc.
throw;
}
catch
{
# Consider this a transient error and continue to the next item.
# Increase the error counter and move onto the next item for processing.
$_;
$Statistics.Value.Errors++;
}
}
}
}
catch
{
# Being in here means we hit a fatal error and can't continue.
$Statistics.Value.Errors++;
$Statistics.Successful = $false;
throw;
}
}
#endregion
<#
Script body definition.
#>
#region Preamble.
$Timestamp = [datetime]::Now;
$Threshold = $Timestamp.AddDays($RetentionPeriod * -1);
#region Check source and desination locations exist.
if (-not (Test-Path -Path $Source -PathType Container -ErrorAction:Stop))
{
throw "Source path does not exist or is not a directory.`n$Source";
}
if (-not (Test-Path -Path $Destination -PathType Container -ErrorAction:Stop))
{
throw "Destination path does not exist or is not a directory.`n$Destination";
}
#endregion
#region Parse source and destination into full paths.
try
{
$Source = (Resolve-Path -Path $Source -ErrorAction:Stop).Path;
$Destination = (Resolve-Path -Path $Destination -ErrorAction:Stop).Path;
}
catch
{
# Not being able to resolve either path is fatal and we can't continue.
throw;
}
#endregion
#region Set up some basic stats to return to the caller.
$Statistics = [PSCustomObject] @{
Copied = 0;
Skipped = 0; # Skipped means the source and destination file/folder have not changed and therefore no copy was triggered.
Errors = 0; # The number of non-fatal errors. Currently, we're only treating "access denied" as fatal since permissions issues aren't simply going to fix themselves.
Deleted = 0; # This is the sum of files deleted from the destination through either being older than the retention
Successful = $true;
}
#endregion
#endregion
#region Start copying.
Copy-FileSystemObject -Source $Source -Destination $Destination -Force ([switch]::new($Force.IsPresent)) -Statistics ([ref] $Statistics);
#endregion
#region Delete files from the destination where the LastAccessDate < Threshold.
Get-ChildItem -Path "$Destination\*" -ErrorAction:Stop |
ForEach-Object {
if ($_.LastAccessTime -lt $Threshold)
{
try
{
Remove-Item -Path ($_.FullName) -Force -ErrorAction:Stop;
}
catch [System.UnauthorizedAccessException]
{
# Access denied is a non-transient error and therefore fatal. We need to bail in this case.
$Statistics.Errors++;
throw;
}
catch
{
# Treat anything else as transient and continue.
$_;
$Statistics.Errors++;
}
}
}
#endregion
# Output the statistics.
$Statistics;
I didn't test much so you'd want to do so before using it for real, if you're inclined to do so.
And it goes without saying as a standard disclaimer that you use this at your own risk (as is the case for anything obtained via the community.)
Cheers,
Lain
Edited for various spelling and grammar corrections.