Powershell Backup

#requires -version 3.0
 
#### PowerShell Backup
#### Version 1.18
#### Last updated 2014-02-25
#### The canonical version of this script is at http://tallguyracing.com/wiki/doku.php?id=powershell_backup
 
Param($backupType)
 
$rootBackupDirectory = 'D:\Backups'
$remoteBackupDirectory = 'P:\StephenH\Backups'
$minHoursBetweenFullBackup = 20
 
$tfExe = 'C:\Program Files (x86)\Microsoft Visual Studio 11.0\Common7\IDE\TF.exe'
$7zExe = 'C:\Program Files\7-Zip\7z.exe'
$tfsServer = 'cybertron.provoke.co.nz'
 
$incrBackupFilenameMask = "Incr_Backup_{0:00}.7z"
$full0BackupFilenameMask = "Full_L0_Backup_{0:00}.7z"
$full1BackupFilenameMask = "Full_L1_Backup_{0:00}.7z"
 
$firstIncrBackupFilename = "Incr_Backup_00.7z"
$firstFull0BackupFilename = "Full_L0_Backup_00.7z"
$firstFull1BackupFilename = "Full_L1_Backup_00.7z"
 
$incrListingFilename = "Incr_Listing.txt"
$full0ListingFilename = "Full_L0_Listing.txt"
 
#### Logging code start
 
$LogHeader  = 0;
$LogError   = 1;
$LogWarning = 2;
$LogMessage = 3;
$LogDebug   = 4;
 
$DisplayLevel = 3;
 
$LogRootDirectory = "D:\Backups\Logs"
$LogFileSizeLimit = 50 * 1024 * 1024
$LogFileResizeLines = 100000
# When a log file size exceeds $LogFileSizeLimit bytes and SizeLimitLogFiles is called, the log file will be
# truncated to the last $LogFileResizeLines lines. 10000 lines of text = approx 1mb file size.
 
$LogFiles = @(
	, @($LogDebug,   "$LogRootDirectory\Debug.txt")
	, @($LogMessage, "$LogRootDirectory\Message.txt")
);
# Note that an extra comma has deliberately been inserted before the first element. This prevents PowerShell
# expanding out the first element.
 
Function Write-Log ($logType, $message)
{
	$formattedMessage = Get-FormatedLog $logType $message
 
	foreach ($logFile in $LogFiles)
	{
		if ($logType -le $logFile[0])
		{
			Add-Content -Path $logFile[1] -Value $formattedMessage
		}
	}
 
	Write-LogHost $logType $formattedMessage
}
 
Function Get-FormatedLog ($logType, $message)
{
	If (($logType -eq $LogHeader) -and ($message -ne ""))
	{
		$message = "**** " + $message + " ****"
	}
	ElseIf ($logType -eq $LogError)
	{
		$message = "ERROR: " + $message
	}
	ElseIf ($logType -eq $LogWarning)
	{
		$message = "Warning: " + $message
	}
 
	$now = Get-Date
	$prefix = $now.ToString("yyyyMMdd HHmmssff: ")
	$message = $prefix + $message.Replace("`n",  "`n" + $prefix)
 
	return $message
}
 
Function Write-LogHost ($logType, $message)
{
	If ($logType -gt $DisplayLevel)
	{
		return
	}
 
	If ($logType -eq $LogHeader)
	{
		Write-Host ($message) -f green
	}
	ElseIf ($logType -eq $LogError)
	{
		Write-Host ($message) -f red
	}
	ElseIf ($logType -eq $LogWarning)
	{
		Write-Host ($message) -f yellow
	}
	ElseIf ($logType -eq $LogDebug)
	{
		Write-Host ($message) -f white
	}
	Else
	{
		Write-Host ($message)
	}
}
 
Function SizeLimitLogFiles()
{
	Write-Log $LogMessage "Checking the size of the log files."
 
	foreach ($logFile in $LogFiles)
	{
		$logFilename = $logFile[1]
		$length = (New-Object io.FileInfo $logFilename).Length
 
		Write-Log $LogDebug "logFilename = '$logFilename'"
		Write-Log $LogDebug "length = $length"
 
		if ($length -gt $LogFileSizeLimit)
		{
			Write-Log $LogMessage "Resizing the log file '$logFilename'."
			(Get-Content $logFilename)[-$LogFileResizeLines .. -1] | Set-Content $logFilename
		}
	}
}
 
#### Logging code end
 
Function Roll ($fileNameMask, $limit)
{
	Write-Log $LogMessage ("Rolling " + ($fileNameMask -f "xx"))
 
	Write-Log $LogDebug "Roll: limit = $limit"
	$lastFileName = ($fileNameMask -f ($limit + 0))
	Write-Log $LogDebug "Roll: lastFileName = '$lastFileName'"
 
	if (Test-Path $lastFileName)
	{
		Write-Log $LogDebug "Removing '$lastFileName'"
		Remove-Item $lastFileName
	}
 
	for ($i = $limit - 1; $i -ge 0; $i--)
	{
		$fromFileName = ($fileNameMask -f $i)
		$toFileName   = ($fileNameMask -f ($i + 1))
 
		if (Test-Path $fromFileName)
		{
			Write-Log $LogDebug "Renaming '$fromFileName' to '$toFileName'"
			Rename-Item $fromFileName $toFileName
		}
	}
}
 
Function Coalesce($a, $b) { if ($a -ne $null) { $a } else { $b } }
 
Function GetFileDescriptions($directory)
{
	(Get-ChildItem -Recurse -Path $directory *.* | ForEach-Object { "{0};{1};{2:yyyyMMddhhmmss}" -f $_.FullName, (Coalesce $_.Length 0), $_.LastWriteTimeUtc } | Sort | Out-String -Width 999999).TrimEnd()
}
 
function GetFileDescriptionsFromList($changedFilesList)
{
	($changedFilesList -split "`n" | ForEach-Object { New-Object IO.FileInfo ($_.Trim()) } | ForEach-Object { "{0};{1};{2:yyyyMMddhhmmss}" -f $_.FullName, (Coalesce $_.Length 0), $_.LastWriteTimeUtc } | Sort | Out-String -Width 999999).TrimEnd()
}
 
Function GetFlags ($contentType)
{
	switch ($contentType)
	{
		'all' { return '' }
		'tfs' { return '' }
		'cs' { return '-x!*.dll', '-x!*.zip', '-x!*.pdf', '-x!*.pdb', '-x!*.exe', '-x!*.mp3', '-x!*.gif', '-x!*.png', '-x!*.jpg', '-x!*.jpeg', '-x!*.SnsDelta', '-x!*.SnsIndex', '-x!*.dbmdl', '-x!*.svn-base', '-x!*.nupkg', '-x!*.scc', '-x!*.rdl.data', '-x!*.cache', '-x!$tf', '-x!PAF2_V200?Q?V0?_DELIVERY_ADDRESSES*' }
 
		default
		{
			Write-Log $LogError "Unknown content type: $contentType"
			Exit(-1)
		}
	}
}
 
function Is7zFileEmpty($archiveFilename)
{
	$archiveContents = (& "C:\Program Files\7-Zip\7z.exe" l $archiveFilename | Out-String -Width 999999).TrimEnd()
 
	$7zExitCode = $LastExitCode
	Write-Log $LogDebug $archiveContents
 
	if ($7zExitCode -gt 0)
	{
		Write-Log $LogError "An error occurred calling 7z. Exit code was $7zExitCode."
		Exit(-1)
	}
 
	return $archiveContents.EndsWith("0 files, 0 folders")
}
 
function GetTfsChangedFiles($backupSourceDirectory)
{
	Set-Location $backupSourceDirectory
	$tfsStatus = (& $tfExe status)
	$tfsExitCode = $LastExitCode
 
	if ($tfsExitCode -gt 1) {
		Write-Log $LogError "An error occurred calling TF. Exit code was $tfsExitCode."
		Exit(-1)
	}
 
	if (! ($tfsStatus[0] -match "Change\s+Local path")) {
		Write-Log $LogError "Could not parse the TF status - could not find the headings."
		Exit(-1)
	}
 
	#Write-Log $LogDebug "GetTfsChangedFiles: tfsStatus = $tfsStatus"
	$changeIndex = $tfsStatus[0].IndexOf($Matches[0])
	#Write-Log $LogDebug "GetTfsChangedFiles: changeIndex = $changeIndex"
 
	if ($changeIndex -le 0)
	{
		Write-Log $LogError "Could not parse the TF status - could not determine the index of the headings." 
		Exit(-1)
	}
 
	$tfsStatus = $tfsStatus -replace "^.{$changeIndex}", '' | Select-String -Pattern '^(add    |edit   )(.+)$' | Out-String -Width 999999
	#Write-Log $LogDebug "GetTfsChangedFiles: tfsStatus = $tfsStatus"
	$tfsStatus.Trim().Replace('add    ', '').Replace('edit   ', '')
}
 
function Backup ($backupName, $backupSourceDirectory, $backupType, $contentType, $maxBackupCount, $maxLevels, $levelReduceFactor)
{
	Write-Log $LogHeader "$backupName $backupType"
 
	Write-Log $LogDebug "Backup: backupName = $backupName"
	Write-Log $LogDebug "Backup: backupSourceDirectory = $backupSourceDirectory"
	Write-Log $LogDebug "Backup: backupType = $backupType"
	Write-Log $LogDebug "Backup: contentType = $contentType"
	Write-Log $LogDebug "Backup: maxBackupCount = $maxBackupCount"
	Write-Log $LogDebug "Backup: maxLevels = $maxLevels"
	Write-Log $LogDebug "Backup: levelReduceFactor = $levelReduceFactor"
 
	$backupDestinationDirectory = "$rootBackupDirectory\$backupName"
	$flags = GetFlags($contentType)
 
	Write-Log $LogDebug "Backup: backupDestinationDirectory = $backupDestinationDirectory"
	Write-Log $LogDebug "Backup: flags = $flags"
 
	switch ($backupType)
	{
		"quick" { 
			$listingFilename = $incrListingFilename
			$backupFilenameMask = $incrBackupFilenameMask
		}
		"full" {
			$listingFilename = $full0ListingFilename
			$backupFilenameMask = $full0BackupFilenameMask
		}
		default
		{
			Write-Log $LogError "Unknown backup type: $backupType"
			Exit(-1)
		}
	}
	$listingFullFilename = "$backupDestinationDirectory\$listingFilename"
	$currentBackupFilename = $backupFilenameMask -f 0
	$currentBackupFullFilename = "$backupDestinationDirectory\$currentBackupFilename"
	$tempBackupFilename = $backupFilenameMask -f "xx"
	$tempBackupFullFilename = "$backupDestinationDirectory\$tempBackupFilename"
	$currentFull0BackupFullFilename = "$backupDestinationDirectory\$firstFull0BackupFilename"
	$currentFull1BackupFullFilename = "$backupDestinationDirectory\$firstFull1BackupFilename"
 
	Write-Log $LogDebug "Backup: listingFullFilename = $listingFullFilename"
	Write-Log $LogDebug "Backup: currentBackupFilename = $currentBackupFilename"
	Write-Log $LogDebug "Backup: currentBackupFullFilename = $currentBackupFullFilename"
	Write-Log $LogDebug "Backup: tempBackupFilename = $tempBackupFilename"
	Write-Log $LogDebug "Backup: tempBackupFullFilename = $tempBackupFullFilename"
	Write-Log $LogDebug "Backup: currentFull0BackupFullFilename = $currentFull0BackupFullFilename"
	Write-Log $LogDebug "Backup: currentFull1BackupFullFilename = $currentFull1BackupFullFilename"
 
	if (! (Test-Path -path $backupSourceDirectory))
	{
		Write-Log $LogWarning "The backup source directory '$backupSourceDirectory' could not be accessed. Backup not done."
		return
	}
 
	if (! (Test-Path -path $backupDestinationDirectory))
	{
		mkdir $backupDestinationDirectory
	}
 
	# Limit full backups to $minHoursBetweenFullBackup
	if (($backupType -eq 'full') -and (Test-Path -path $listingFullFilename))
	{
		$timeSinceLastFullBackup = $(Get-Date) - (Get-Item $currentBackupFullFilename).LastWriteTime
 
		if ($timeSinceLastFullBackup.TotalHours -lt $minHoursBetweenFullBackup)
		{
			Write-Log $LogMessage 'Skipping backup as not enough time has passed since the last full backup.'
			return
		}
	}
 
	if ($contentType -eq "tfs")
	{
		# Ping the TFS Server to make sure it is alive.
		$ping = New-Object System.Net.NetworkInformation.Ping
		$pingResult = $ping.Send($tfsServer, 2000)
		if ($pingResult.Status -ne 'Success')
		{
			Write-Log $LogWarning "The TFS server '$tfsServer' is not currently available. A ping returned a status of '$($pingResult.Status)'. Backup skipped."
			return
		}
 
		$tfsChangedFiles = GetTfsChangedFiles $backupSourceDirectory
 
		if ($tfsChangedFiles.Length -eq 0) {
			Write-Log $LogMessage "There are no changed files."
			return
		}
 
		#Write-Log $LogDebug "Backup: tfsChangedFiles = $tfsChangedFiles"
		$currentListing = GetFileDescriptionsFromList $tfsChangedFiles
		#Write-Log $LogDebug "Backup: currentListing = $currentListing"
	}
	else
	{
		$currentListing = GetFileDescriptions $backupSourceDirectory
	}
	Write-Log $LogDebug "Backup: currentListing = $currentListing"
 
	# Ensure that the temp file does not exist.
	if (Test-Path -path $tempBackupFullFilename)
	{
		Write-Log $LogWarning "The temporary backup file '$tempBackupFullFilename' was not cleaned up."
 
		Remove-Item $tempBackupFullFilename
		if (Test-Path -path $tempBackupFullFilename)
		{
			Write-Log $LogError "Could not remove the temporary backup file '$tempBackupFullFilename'."
			Exit(-1)
		}
	}
 
	if (Test-Path -path $listingFullFilename)
	{
		$lastListing = ([Io.File]::ReadAllText($listingFullFilename)).TrimEnd()
 
		if ($lastListing -eq $currentListing)
		{
			# Update only the listing file to indicate that a 'backup check' was done, but leave the actual backup file to indicate
			# when something was actually last changed.
			(Get-Item $listingFullFilename).LastWriteTime = Get-Date
 
			Write-Log $LogMessage "Nothing has changed since the last backup. A new backup is not required."
 
			return
		}
	}
	$currentListing > $listingFullFilename
	Write-Log $LogMessage "Changes have occurred since the last backup. A new backup is required."
 
	if (($backupType -eq "quick") -and ! (Test-Path -path $currentFull0BackupFullFilename))
	{
		Backup $backupName $backupSourceDirectory "full" $contentType $maxBackupCount $maxLevels $levelReduceFactor
 
		return
	}
 
	if ($contentType -eq "tfs")
	{
		$tfsChangedFiles
 
		$listFileName = "$backupDestinationDirectory\listFile.txt"
		Write-Log $LogDebug "Backup: listFileName = $listFileName"
		Write-Log $LogDebug "Backup: backupSourceDirectory = $backupSourceDirectory"
 
		$tfsChangedFiles.Trim().Replace("$backupSourceDirectory\", '') | Out-File $listFileName -Encoding utf8
 
		$7zBackupSource = "@$listFileName"
	}
	else
	{
		$7zBackupSource = $backupSourceDirectory
	}
	Write-Log $LogDebug "Backup: 7zBackupSource = $7zBackupSource"
 
	switch ($backupType)
	{
		"quick" { $7zOutput = (& $7zExe u -r $flags $currentFull0BackupFullFilename -u- "-up0q0x2y2z0w2!$tempBackupFullFilename" $7zBackupSource | Out-String -Width 999999) }
		"full"  { $7zOutput = (& $7zExe a -r $flags $tempBackupFullFilename $7zBackupSource | Out-String -Width 999999) }
		default
		{
			Write-Log $LogError "Unknown backup type: $backupType" 
			Exit(-1)
		}
	}
	$7zExitCode = $LastExitCode
	Write-Log $LogDebug $7zOutput
 
	if ($listFileName)
	{
		Write-Log $LogDebug "Removing '$listFileName'"
		Remove-Item $listFileName
	}
 
	if ($7zExitCode -gt 1) {
		Write-Log $LogError "An error occurred calling 7z. Exit code was $7zExitCode."
		Exit(-1)
	}
 
	if ($7zExitCode -eq 1) {
		Write-Log $LogWarning "7z returned 1 (warning - non fatal error)."
	}
 
	if (Is7zFileEmpty($tempBackupFullFilename))
	{
		Write-Log $LogMessage "New backup file is empty - throwing it away."
		Remove-Item $tempBackupFullFilename
		return
	}
	Write-Log $LogMessage "Backup created successfully."
 
	Roll "$backupDestinationDirectory\$backupFilenameMask" $maxBackupCount
	Move-Item $tempBackupFullFilename $currentBackupFullFilename
 
	if (($backupType -eq "full") -and ($remoteBackupDirectory) -and (Test-Path -path $remoteBackupDirectory))
	{
		$remoteBackupFullFilename = "$remoteBackupDirectory\$backupName.7z"
		Copy-Item $currentBackupFullFilename $remoteBackupFullFilename -Force
	}
 
	if ($backupType -eq "full")
	{
		$backupFullMask = "$backupDestinationDirectory\Full_L{0}_Backup_{1:00}.7z"
		$sourceLevel = 0
		BackupHigherFull $backupName $backupFullMask $sourceLevel $maxBackupCount $maxLevels $levelReduceFactor
	}
}
 
function BackupHigherFull ($backupName, $backupFullMask, $sourceLevel, $maxBackupCount, $maxLevel, $levelReduceFactor)
{
	$targetLevel = $sourceLevel + 1
	Write-Log $LogHeader "$backupName Full Level $targetLevel"
 
	Write-Log $LogDebug "BackupHigherFull: backupName = $backupName"
	Write-Log $LogDebug "BackupHigherFull: backupFullMask = $backupFullMask"
	Write-Log $LogDebug "BackupHigherFull: sourceLevel = $sourceLevel"
	Write-Log $LogDebug "BackupHigherFull: maxBackupCount = $maxBackupCount"
	Write-Log $LogDebug "BackupHigherFull: maxLevel = $maxLevel"
	Write-Log $LogDebug "BackupHigherFull: levelReduceFactor = $levelReduceFactor"
 
	$currentBackupFullFilename    = $backupFullMask -f (([Int]$sourceLevel), 0)
	$comparatorBackupFullFilename = $backupFullMask -f (([Int]$sourceLevel), ([Int]$levelReduceFactor - 1))
	$targetBackupFullFilename     = $backupFullMask -f (([Int]$targetLevel), 0)
	$targetBackupMask             = $backupFullMask -f (([Int]$targetLevel), "{0:00}")
 
	Write-Log $LogDebug "BackupHigherFull: currentBackupFullFilename = $currentBackupFullFilename"
	Write-Log $LogDebug "BackupHigherFull: comparatorBackupFullFilename = $comparatorBackupFullFilename"
	Write-Log $LogDebug "BackupHigherFull: targetBackupFullFilename = $targetBackupFullFilename"
	Write-Log $LogDebug "BackupHigherFull: targetBackupMask = $targetBackupMask"
 
	if (! (Test-Path -path $currentBackupFullFilename))
	{
		Write-Log $LogError "A current level $sourceLevel backup should exist."
		Exit(-1)
	}
 
	if (! (Test-Path -path $targetBackupFullFilename))
	{
		Write-Log $LogMessage "Saving as there is no existing level $targetLevel backup."
		Copy-Item $currentBackupFullFilename $targetBackupFullFilename
		return
	}
 
	if (! (Test-Path -path $comparatorBackupFullFilename))
	{
		Write-Log $LogMessage "Skipping as there is no comparator level $sourceLevel backup yet."
		return
	}
 
	if ((Get-Item $comparatorBackupFullFilename).LastWriteTime -le (Get-Item $targetBackupFullFilename).LastWriteTime)
	{
		Write-Log $LogMessage "Skipping as the current level $targetLevel backup does not require replacement yet."
		return
	}
 
	Write-Log $LogMessage "Creating a level $targetLevel backup."
	Roll $targetBackupMask $maxBackupCount
	Copy-Item $currentBackupFullFilename $targetBackupFullFilename
 
	if ($targetLevel -lt $maxLevel)
	{
		BackupHigherFull $backupName $backupFullMask $targetLevel $maxBackupCount $maxLevel $levelReduceFactor
	}
}
 
function BackupAll ($backupType)
{
	# NOTE: When type = tfs, do not put a trailing slash on backupSourceDirectory.
	Write-Log $LogHeader "---- Backup All $backupType ---- Start ----"
 
	Backup 'Dev' 'D:\Dev' $backupType 'tfs' 20 2 6
	Backup 'Bin' 'C:\Bin\*' $backupType 'all' 20 2 6
	Backup 'VS2010Settings' 'C:\Users\stephenh\Documents\Visual Studio 2010\*' $backupType 'all' 20 2 6
	Backup 'VS2012Settings' 'C:\Users\stephenh\Documents\Visual Studio 2012\*' $backupType 'all' 20 2 6
	Backup 'BigPrimes' 'C:\Not so temp\BigPrimes' $backupType 'all' 20 2 6
	Backup 'Desktop' 'C:\Users\stephenh\Desktop\*' $backupType 'all' 20 2 6
 
	CopyBinToFlicky
 
	if ($backupType -eq 'full')
	{
		SizeLimitLogFiles
	}
 
	Write-Log $LogHeader "---- Backup All $backupType ---- Done ----"
}
 
#### Oddball Backups #####
 
function CopyBinToFlicky ()
{
	Write-Log $LogHeader '---- Copy Bin To Flicky ---- Start ----'
 
	$fSyncExe = 'C:\Bin\Fsync\Fsync.exe'
	$localBinDir = 'C:\Bin'
	$flickyBinDir = 'C:\External Drives\Flicky\Dropbox\Bins\Provoke\Bin'
 
	if (! (Test-Path -path $flickyBinDir ))
	{
		Write-Log $LogMessage "Flicky is not plugged in. Copy skipped."
		return
	}
 
	$fSyncOutput = (& $fSyncExe $localBinDir $flickyBinDir | Out-String -Width 999999)
	Write-Log $LogDebug $fSyncOutput
 
	Write-Log $LogHeader '---- Copy Bin To Flicky ---- Done ----'
}
 
###########################
 
#CopyBinToFlicky
#exit(0)
 
#$DisplayLevel = 4;
#$backupType = 'test'
 
switch ($backupType)
{
	'quick'   { BackupAll 'quick' }
	'full'    { BackupAll 'full' }
	'test'    { Backup 'Desktop' 'C:\Users\stephenh\Desktop\*' 'full' 'all' 20 2 6 }
	'devtest' { Backup 'Dev' 'D:\Dev' 'quick' 'tfs' 20 2 6 }
	'tfstest' { GetTfsChangedFiles 'D:\Dev' }
	default   { Write-Host 'You are an idiot.' }
}
 
#Write-Host "Press any key to exit."
#$dontcare = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")