DailyBackup.psm1
$script:ErrorActionPreference = 'Stop' $script:ProgressPreference = 'SilentlyContinue' # ----------------------------------------------- # - Date format: yyyy-mm-dd # - Date range: 1900-01-01 through 2099-12-31 # - Matches invalid dates such as February 31st # - Accepts dashes, forward slashes and dots as date separators. # ----------------------------------------------- $script:DefaultFolderDateFormat = 'yyyy-MM-dd' $script:DefaultFolderDateRegex = '\b(19|20)[0-9]{2}[-/.](0[1-9]|1[012])[-/.](0[1-9]|[12][0-9]|3[01])\b' # ----------------------------------------------- function GetRandomFileName { <# .SYNOPSIS Generates a random file name. .DESCRIPTION Generates a random file name without the file extension. .OUTPUTS [String] #> $randomFileName = [System.IO.Path]::GetRandomFileName() return $randomFileName.Substring(0, $randomFileName.IndexOf('.')) } function GenerateBackupPath { <# .SYNOPSIS Generates a backup file name. .DESCRIPTION Generates a backup file name by replacing directory seperator characters and spaces with underscores. .PARAMETER Path The source path for the backup. .PARAMETER DestinationPath The destination path of the compressed file. .PARAMETER VerboseEnabled Whether or not invoke commands with the -Verbose parameter. .OUTPUTS [String] #> param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $DestinationPath ) # Removes the drive part (e.g. 'C:') $pathWithoutPrefix = (Split-Path -Path $Path -NoQualifier) # replace directory seperators with underscores $backupName = ($pathWithoutPrefix -replace '[\\/]', '__').Trim('__') $backupPath = Join-Path -Path $DestinationPath -ChildPath $backupName if ((Test-Path -Path "$backupPath.zip")) { $randomFileName = (GetRandomFileName) $backupPath = ('{0}__{1}' -f $backupPath, $randomFileName) Write-Warning ("New-DailyBackup:GenerateBackupPath> A backup with the same filename '{0}' already exists in destination path '{1}', '{2}' was automatically appended to the backup filename for uniqueness" -f "$backupName.zip", $DestinationPath, $randomFileName) } if ($backupPath.Length -ge 255) { Write-Error ('New-DailyBackup:GenerateBackupPath> The backup file path ''{0}'' is greater than or equal the maximum allowed filename length (255)' -f $backupPath) -ErrorAction Stop } return $backupPath } function CompressBackup { <# .SYNOPSIS Creates a compressed archive. .DESCRIPTION Creates a compressed archive, or zipped file, from specified files and or directories. .PARAMETER Path The path of the file or directory to compress. .PARAMETER DestinationPath The destination path of the compressed file. .PARAMETER DryRun Whether or not to perform the Compress-Archive operation. Internally sets the value of the -WhatIf parameter when running the Compress-Archive cmdlet. .PARAMETER VerboseEnabled Whether or not invoke commands with the -Verbose parameter. #> param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $DestinationPath, [Parameter(Mandatory = $false)] [bool] $DryRun = $false, [Parameter(Mandatory = $false)] [bool] $VerboseEnabled = $false ) $backupPath = GenerateBackupPath -Path $Path -DestinationPath $DestinationPath if ($DryRun) { Write-Verbose ('New-DailyBackup:CompressBackup> Dry-run only, backup ''{0}'' will not be created' -f "$backupPath.zip") } else { Write-Verbose ('New-DailyBackup:CompressBackup> Compressing backup ''{0}''' -f "$backupPath.zip") Compress-Archive -LiteralPath $Path -DestinationPath "$backupPath.zip" -WhatIf:$DryRun -Verbose:$VerboseEnabled -ErrorAction Continue } } function Remove-ItemAlternative { <# .SYNOPSIS Removes all files and folders within given path. .DESCRIPTION Removes all files and folders within given path. A workaround for the access denied issue when attempting to Remove-Item(s) from an Apple iCloud or OneDrive path. .PARAMETER LiteralPath Path to location. The value of LiteralPath is used exactly as it's typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose it in single quotation marks. Single quotation marks tell PowerShell not to interpret any characters as escape sequences. .PARAMETER SkipTopLevelFolder If present, the top-level folder will not be deleted. .EXAMPLE Remove-ItemAlternative -LiteralPath "C:\Support\GitHub\GpoZaurr\Docs" .NOTES https://evotec.xyz/remove-item-access-to-the-cloud-file-is-denied-while-deleting-files-from-onedrive/ https://jonlabelle.com/snippets/view/powershell/powershell-remove-item-access-denied-fix #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter()] [string] $LiteralPath, [Parameter()] [switch] $SkipTopLevelFolder ) if ($LiteralPath -and (Test-Path -LiteralPath $LiteralPath)) { $items = Get-ChildItem -LiteralPath $LiteralPath -Recurse foreach ($item in $items) { if ($item.PSIsContainer -eq $false) { try { if ($PSCmdlet.ShouldProcess($item.Name)) { $item.Delete() } } catch { Write-Warning "New-DailyBackup:Remove-ItemAlternative> Couldn't delete $($item.FullName), error: $($_.Exception.Message)" } } } $items = Get-ChildItem -LiteralPath $LiteralPath -Recurse foreach ($item in $items) { try { if ($PSCmdlet.ShouldProcess($item.Name)) { $item.Delete() } } catch { Write-Warning "New-DailyBackup:Remove-ItemAlternative> Couldn't delete '$($item.FullName)', Error: $($_.Exception.Message)" } } if (-not $SkipTopLevelFolder) { $item = Get-Item -LiteralPath $LiteralPath try { if ($PSCmdlet.ShouldProcess($item.Name)) { $item.Delete($true) } } catch { Write-Warning "New-DailyBackup:Remove-ItemAlternative> Couldn't delete '$($item.FullName)', Error: $($_.Exception.Message)" } } } else { Write-Warning "New-DailyBackup:Remove-ItemAlternative> Path '$Path' doesn't exist. Skipping." } } function RemoveDailyBackup { <# .SYNOPSIS Delete daily backups. .DESCRIPTION Delete daily backups with an option to keep minimum number of previous backups, deleting the oldest backups first. .PARAMETER Path The root path where backups are stored. .PARAMETER BackupsToKeep The minimum number of backups to keep before deleting. .PARAMETER DryRun Whether or not to perform the actual delete operation. Internally sets the value of the -WhatIf parameter when running the Remove-Item cmdlet. .PARAMETER VerboseEnabled Whether or not invoke commands with the -Verbose parameter. #> param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [int] $BackupsToKeep, [Parameter(Mandatory = $false)] [bool] $DryRun = $false, [Parameter(Mandatory = $false)] [bool] $VerboseEnabled = $false ) $qualifiedBackupDirs = @(Get-ChildItem -LiteralPath $Path -Directory -Depth 1 -ErrorAction 'SilentlyContinue' | Where-Object { $_.Name -cmatch $script:DefaultFolderDateRegex }) if ($qualifiedBackupDirs.Length -eq 0) { Write-Verbose ('New-DailyBackup:RemoveDailyBackup> No qualified backup directories to delete were detected in: {0}' -f $Path) -Verbose:$VerboseEnabled return } # Create a hashtable so we can sort backup directories based on their date-formatted folder name ('yyyy-MM-dd') $backups = @{ } foreach ($backupDir in $qualifiedBackupDirs) { $backups.Add($backupDir.FullName, [System.DateTime]$backupDir.Name) } $sortedBackupPaths = ($backups.GetEnumerator() | Sort-Object -Property Value | ForEach-Object { $_.Key }) if ($sortedBackupPaths.Count -gt $BackupsToKeep) { for ($backup = 0; $backup -lt ($sortedBackupPaths.Count - $BackupsToKeep); $backup++) { $backupPath = $sortedBackupPaths[$backup] Remove-ItemAlternative -LiteralPath $backupPath -WhatIf:$dryRun -Verbose:$verboseEnabled } } else { Write-Verbose 'New-DailyBackup:RemoveDailyBackup> No surplus daily backups to delete' -Verbose:$VerboseEnabled } } function New-DailyBackup { <# .SYNOPSIS Perform a daily backup. .DESCRIPTION Create a new daily backup storing the compressed (.zip) contents in a destination folder formatted by day ('yyyy-MM-dd'). .PARAMETER Path The source files or directory path(s) to backup. .PARAMETER Destination The root directory path where daily backups will be stored. .PARAMETER DailyBackupsToKeep The number of daily backups to keep when purging old backups. The oldest backups will be deleted first. This value cannot be less than zero. .EXAMPLE To import the DailyBackup module in your session: Import-Module DailyBackup .EXAMPLE To create a new daily backup from a list of paths: New-DailyBackup -Path $('source/path/1', 'source/path/2') -Destination 'root/destination/directory' -Verbose .EXAMPLE To perform a dry-run of the daily backup operation: New-DailyBackup -Path source/path -Destination destination/path -WhatIf .LINK https://github.com/jonlabelle/pwsh-daily-backup #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter( Position = 0, Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, HelpMessage = 'The source files or directory path(s) to backup.') ] [Alias('PSPath', 'FullName', 'SourcePath')] [string[]] $Path, [Parameter( Position = 1, Mandatory = $true, HelpMessage = 'The root directory path where daily backups will be stored.') ] [Alias('DestinationPath', 'TargetPath')] [string] $Destination, [Parameter( Mandatory = $true, HelpMessage = 'The number of daily backups to keep when purging old backups.' )] [ValidateNotNullOrEmpty()] [Alias('Keep')] [int] $DailyBackupsToKeep ) begin { $verboseEnabled = $false if ($VerbosePreference -eq 'Continue') { $verboseEnabled = $true Write-Verbose 'New-DailyBackup:Begin> Verbose mode is enabled' -Verbose:$verboseEnabled } $dryRun = $true if (-not $WhatIfPreference) { Write-Verbose 'New-DailyBackup:Begin> Dry-run is not enabled' -Verbose:$verboseEnabled $dryRun = $false } else { Write-Verbose 'New-DailyBackup:Begin> Dry-run is enabled' -Verbose:$verboseEnabled } if ($DailyBackupsToKeep -lt 0) { Write-Error ('New-DailyBackup:Begin> DailyBackupsToKeep parameter cannot be less than zero' -f $DailyBackupsToKeep) exit 1 } $folderName = (Get-Date -Format $script:DefaultFolderDateFormat) $datedDestinationDir = (Join-Path -Path $Destination -ChildPath $folderName) if ((Test-Path -Path $datedDestinationDir -PathType Container)) { Write-Verbose ('New-DailyBackup:Begin> Removing existing backup destination directory: {0}' -f $datedDestinationDir) -Verbose:$verboseEnabled Remove-ItemAlternative -LiteralPath $datedDestinationDir -WhatIf:$dryRun -Verbose:$verboseEnabled } Write-Verbose ('New-DailyBackup:Begin> Creating backup destination directory: {0}' -f $datedDestinationDir) -Verbose:$verboseEnabled New-Item -Path $datedDestinationDir -ItemType Directory -WhatIf:$dryRun -Verbose:$verboseEnabled -ErrorAction 'SilentlyContinue' | Out-Null } process { foreach ($item in $Path) { if (-not [System.IO.Path]::IsPathRooted($item)) { Write-Verbose ('New-DailyBackup:Process> {0} is not a full path, prepending current directory: {1}' -f $item, $pwd) -Verbose:$verboseEnabled $item = (Join-Path -Path $pwd -ChildPath $item) } $resolvedPath = (Resolve-Path $item -ErrorAction SilentlyContinue -Verbose:$verboseEnabled).ProviderPath if ($null -eq $resolvedPath) { Write-Warning ('New-DailyBackup:Process> Failed to resolve path for: {0}' -f $item) Continue } if ($resolvedPath.Count -gt 1) { foreach ($globItem in $resolvedPath) { CompressBackup -Path $globItem -DestinationPath $datedDestinationDir -DryRun $dryRun -VerboseEnabled $verboseEnabled } } else { if (!(Test-Path -Path $resolvedPath -IsValid)) { Write-Warning ('New-DailyBackup:Process> Backup source path does not exist: {0}' -f $resolvedPath) } else { CompressBackup -Path $resolvedPath -DestinationPath $datedDestinationDir -DryRun $dryRun -VerboseEnabled $verboseEnabled } } } } end { Write-Verbose 'New-DailyBackup:End> Running post backup operations' -Verbose:$verboseEnabled RemoveDailyBackup -Path $Destination -BackupsToKeep $DailyBackupsToKeep -DryRun $dryRun -VerboseEnabled $verboseEnabled Write-Verbose 'New-DailyBackup:End> Finished' -Verbose:$verboseEnabled } } Export-ModuleMember -Function New-DailyBackup |