param( [Parameter(Mandatory=$true)] [string]$LiveMountRoot, [Parameter(Mandatory=$false)] [string]$DatabaseName, [Parameter(Mandatory=$true)] [ValidateSet("catalog", "restore", "verify")] [string]$Action, [Parameter(Mandatory=$true)] [string]$SqlInstance = "sqlfcsql\TESTINST", [Parameter(Mandatory=$false)] [string]$DataPath, [Parameter(Mandatory=$false)] [string]$LogPath, [Parameter(Mandatory=$false)] [string]$NewName, [Parameter(Mandatory=$false)] [string]$StopAtTime, [Parameter(Mandatory=$false)] [string]$StopAtLSN, [Parameter(Mandatory=$false)] [switch]$PreviewRestorePlan ) # Function to catalog backups function Catalog-Backups { param([string]$Path) $backupFiles = Get-ChildItem -Path $Path -Recurse -File | Where-Object { $_.Extension -eq '.bak' -or $_.Extension -eq '.trn' } $catalog = @{} foreach ($file in $backupFiles) { $baseName = $file.BaseName $parts = $baseName -split '_' if ($parts.Length -lt 4) { continue } # Find the type position (FULL, DIFF, LOG) $typeIndex = -1 $validTypes = @('FULL', 'DIFF', 'LOG') for ($i = 0; $i -lt $parts.Length; $i++) { if ($validTypes -contains $parts[$i]) { $typeIndex = $i break } } if ($typeIndex -eq -1 -or ($parts.Length - $typeIndex) -lt 3) { continue } # Assume parts[0] is prefix if typeIndex > 1, else dbName is parts[0] if ($typeIndex -eq 1) { $dbName = $parts[0] } else { $dbName = $parts[1..($typeIndex-1)] -join '_' } $type = $parts[$typeIndex] $dateStr = $parts[$typeIndex + 1] $timeStr = $parts[$typeIndex + 2] $stripe = 0 if ($parts.Length -gt $typeIndex + 3) { $stripe = [int]$parts[$typeIndex + 3] } $key = "$dateStr$timeStr" if (-not $catalog.ContainsKey($dbName)) { $catalog[$dbName] = @{} } if (-not $catalog[$dbName].ContainsKey($type)) { $catalog[$dbName][$type] = @{} } if (-not $catalog[$dbName][$type].ContainsKey($key)) { $catalog[$dbName][$type][$key] = @() } $catalog[$dbName][$type][$key] += @{File = $file; Stripe = $stripe} } return $catalog } # Function to report catalog function Report-Catalog { param([hashtable]$Catalog) Write-Host "Database Backups Catalog:" Write-Host "=========================" foreach ($db in $catalog.Keys | Sort-Object) { Write-Host "Database: $db" foreach ($type in $catalog[$db].Keys | Sort-Object) { Write-Host " Type: $type" foreach ($key in $catalog[$db][$type].Keys | Sort-Object -Descending) { Write-Host " Backup: $key" $files = $catalog[$db][$type][$key] | Sort-Object { $_.Stripe } foreach ($item in $files) { Write-Host " $($item.File.FullName)" } } } Write-Host "" } Write-Host "Summary of Databases:" $catalog.Keys | Sort-Object | ForEach-Object { Write-Host " - $_" } } # Function to restore database function Restore-Database { param( [string]$DbName, [hashtable]$Catalog, [string]$Instance, [string]$DataPath, [string]$LogPath, [string]$TargetDbName, [string]$StopAtTime, [string]$StopAtLSN, [bool]$PreviewRestorePlan ) $restoreDbName = if ($TargetDbName) { $TargetDbName } else { $DbName } $shouldRenamePhysicalFiles = -not [string]::IsNullOrWhiteSpace($TargetDbName) -and ($TargetDbName -ne $DbName) $isPointInTimeRestore = ($StopAtTime -or $StopAtLSN) $targetStopAtTime = $null $targetStopAtLsn = $null if (-not $catalog.ContainsKey($DbName)) { Write-Error "Database $DbName not found in catalog" return } $dbCatalog = $catalog[$DbName] # Find the latest FULL backup if (-not $dbCatalog.ContainsKey('FULL')) { Write-Error "No FULL backup found for database $DbName" return } if ($StopAtTime) { try { $targetStopAtTime = [datetime]::Parse($StopAtTime) } catch { Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value" return } } if ($StopAtLSN) { $lsnForCompare = if ($StopAtLSN -like 'lsn:*') { $StopAtLSN.Substring(4) } else { $StopAtLSN } try { $targetStopAtLsn = [decimal]$lsnForCompare } catch { Write-Error "StopAtLSN '$StopAtLSN' must be a numeric LSN value" return } } # Select FULL backup. For StopAtTime, choose latest FULL that finished at/before target time. $fullCandidates = @() $fullKeys = $dbCatalog['FULL'].Keys | Sort-Object foreach ($key in $fullKeys) { $files = $dbCatalog['FULL'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $header = Get-BackupInfo -Files $files -Instance $Instance if (-not $header) { Write-Warning "Skipping FULL backup $key for $DbName because its header could not be read" continue } $finish = $null try { $finish = [datetime]$header.BackupFinishDate } catch { Write-Warning "Skipping FULL backup $key for $DbName because BackupFinishDate could not be parsed" continue } if ($targetStopAtTime -and $finish -gt $targetStopAtTime) { continue } $fullCandidates += @{ Key = $key Files = $files Header = $header Finish = $finish } } if ($fullCandidates.Count -eq 0) { if ($targetStopAtTime) { Write-Error "No FULL backup found that is at or before StopAtTime '$StopAtTime' for database $DbName" } else { Write-Error "No usable FULL backup found for database $DbName" } return } $selectedFull = if ($targetStopAtTime) { $fullCandidates | Sort-Object { $_.Finish } -Descending | Select-Object -First 1 } else { $fullCandidates | Sort-Object { $_.Key } -Descending | Select-Object -First 1 } $latestFullKey = $selectedFull.Key $fullFiles = $selectedFull.Files $fullHeader = $selectedFull.Header $fullBaseLsn = $fullHeader.CheckpointLSN if (-not $fullBaseLsn) { $fullBaseLsn = $fullHeader.FirstLSN } # Create directories if specified if ($DataPath -and -not (Test-Path $DataPath)) { New-Item -ItemType Directory -Path $DataPath -Force } if ($LogPath -and -not (Test-Path $LogPath)) { New-Item -ItemType Directory -Path $LogPath -Force } # Get file list for MOVE clauses $fileListStr = $fullFiles | ForEach-Object { "DISK = '$($_.FullName)'" } $fileListOnlyQuery = "RESTORE FILELISTONLY FROM $($fileListStr -join ', ')" $fileListResult = Invoke-Sqlcmd -ServerInstance $Instance -Query $fileListOnlyQuery -QueryTimeout 0 -ErrorAction Stop $moveClauses = @() $dataFileIndex = 0 $logFileIndex = 0 foreach ($file in $fileListResult) { $logicalName = $file.LogicalName $type = $file.Type $originalName = $file.PhysicalName $extension = [System.IO.Path]::GetExtension($originalName) $baseName = [System.IO.Path]::GetFileName($originalName) # When restoring as a different database name, rewrite physical file names # so they do not clash with the source database files on disk. if ($shouldRenamePhysicalFiles) { if ($type -eq 'D') { $dataFileIndex++ $newBaseName = if ($dataFileIndex -eq 1) { "${restoreDbName}_Data" } else { "${restoreDbName}_Data$dataFileIndex" } $baseName = "$newBaseName$extension" } elseif ($type -eq 'L') { $logFileIndex++ $newBaseName = if ($logFileIndex -eq 1) { "${restoreDbName}_Log" } else { "${restoreDbName}_Log$logFileIndex" } $baseName = "$newBaseName$extension" } } if ($type -eq 'D' -and $DataPath) { $newPath = Join-Path $DataPath $baseName $moveClauses += "MOVE '$logicalName' TO '$newPath'" } elseif ($type -eq 'L' -and $LogPath) { $newPath = Join-Path $LogPath $baseName $moveClauses += "MOVE '$logicalName' TO '$newPath'" } } # Build FULL restore query $withClause = if ($moveClauses) { "WITH $($moveClauses -join ', '), NORECOVERY" } else { "WITH NORECOVERY" } $restoreQuery = "RESTORE DATABASE [$restoreDbName] FROM $($fileListStr -join ', ') $withClause" $fullRestoreQuery = $restoreQuery # Choose the latest DIFF that is compatible with the selected FULL. $selectedDiff = $null $selectedDiffKey = $null if ($dbCatalog.ContainsKey('DIFF')) { $compatibleDiffs = @() $diffKeys = $dbCatalog['DIFF'].Keys | Where-Object { $_ -gt $latestFullKey } | Sort-Object foreach ($key in $diffKeys) { $diffFiles = $dbCatalog['DIFF'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $diffHeader = Get-BackupInfo -Files $diffFiles -Instance $Instance if (-not $diffHeader) { Write-Warning "Skipping DIFF backup $key for $DbName because its header could not be read" continue } if ($diffHeader.DifferentialBaseLSN -ne $fullBaseLsn) { continue } $diffFinish = $null try { $diffFinish = [datetime]$diffHeader.BackupFinishDate } catch { Write-Warning "Skipping DIFF backup $key for $DbName because BackupFinishDate could not be parsed" continue } if ($targetStopAtTime -and $diffFinish -gt $targetStopAtTime) { continue } $compatibleDiffs += @{ Key = $key; Files = $diffFiles; Finish = $diffFinish } } if ($compatibleDiffs.Count -gt 0) { $selectedDiff = if ($targetStopAtTime) { $compatibleDiffs | Sort-Object { $_.Finish } -Descending | Select-Object -First 1 } else { $compatibleDiffs | Sort-Object { $_.Key } -Descending | Select-Object -First 1 } $selectedDiffKey = $selectedDiff.Key } } $logStartKey = if ($selectedDiffKey) { $selectedDiffKey } else { $latestFullKey } $logKeys = @() # Select LOG backups after the latest restored FULL/DIFF point. if ($dbCatalog.ContainsKey('LOG')) { $candidateLogKeys = @($dbCatalog['LOG'].Keys | Where-Object { $_ -gt $logStartKey } | Sort-Object) if ($isPointInTimeRestore -and $candidateLogKeys.Count -eq 0) { Write-Error "Point-in-time restore requested but no LOG backups are available after the selected FULL/DIFF restore point" return } if ($StopAtTime) { $cutoffFound = $false foreach ($key in $candidateLogKeys) { $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $logHeader = Get-BackupInfo -Files $logFiles -Instance $Instance if (-not $logHeader) { continue } $logStartTime = $null $logFinishTime = $null try { $logStartTime = [datetime]$logHeader.BackupStartDate $logFinishTime = [datetime]$logHeader.BackupFinishDate } catch { continue } if ($logFinishTime -lt $targetStopAtTime) { $logKeys += $key continue } # A log backup contains all records up to BackupFinishDate. The first log # whose finish time meets/exceeds StopAtTime is the PITR cutoff log. if ($logFinishTime -ge $targetStopAtTime) { $logKeys += $key $cutoffFound = $true break } } if (-not $cutoffFound) { Write-Error "StopAtTime '$StopAtTime' is not covered by available LOG backups after the selected FULL/DIFF restore point" return } } elseif ($StopAtLSN) { $cutoffFound = $false foreach ($key in $candidateLogKeys) { $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $logHeader = Get-BackupInfo -Files $logFiles -Instance $Instance if (-not $logHeader) { continue } $logFirstLsn = $null $logLastLsn = $null try { $logFirstLsn = [decimal]$logHeader.FirstLSN $logLastLsn = [decimal]$logHeader.LastLSN } catch { continue } if ($logLastLsn -lt $targetStopAtLsn) { $logKeys += $key continue } if ($logFirstLsn -le $targetStopAtLsn -and $targetStopAtLsn -le $logLastLsn) { $logKeys += $key $cutoffFound = $true break } if ($logFirstLsn -gt $targetStopAtLsn) { break } } if (-not $cutoffFound) { Write-Error "StopAtLSN '$StopAtLSN' is not covered by available LOG backups after the selected FULL/DIFF restore point" return } } else { $logKeys = $candidateLogKeys } if ($isPointInTimeRestore -and $logKeys.Count -eq 0) { Write-Error "Point-in-time restore requested but no usable LOG backups were selected" return } } elseif ($isPointInTimeRestore) { Write-Error "Point-in-time restore requested but this database has no LOG backups in the catalog" return } if ($PreviewRestorePlan) { Write-Host "" Write-Host "Restore Plan Preview" Write-Host "====================" Write-Host "Source Database: $DbName" Write-Host "Target Database: $restoreDbName" Write-Host "FULL Backup Key: $latestFullKey" Write-Host "FULL Backup Files:" foreach ($f in $fullFiles) { Write-Host " - $($f.FullName)" } if ($selectedDiffKey) { Write-Host "Selected DIFF Key: $selectedDiffKey" Write-Host "Selected DIFF Files:" foreach ($f in $selectedDiff.Files) { Write-Host " - $($f.FullName)" } } else { Write-Host "Selected DIFF Key: none" } if ($logKeys.Count -gt 0) { Write-Host "LOG Range: $($logKeys[0]) -> $($logKeys[-1]) ($($logKeys.Count) backup sets)" } else { Write-Host "LOG Range: none" } if ($isPointInTimeRestore) { if ($StopAtTime) { Write-Host "PITR Target: STOPAT '$StopAtTime'" } else { $previewLsn = if ($StopAtLSN -like 'lsn:*') { $StopAtLSN } else { "lsn:$StopAtLSN" } Write-Host "PITR Target: STOPATMARK '$previewLsn'" } } else { Write-Host "PITR Target: none (restore to latest available point)" } Write-Host "MOVE Clauses:" foreach ($m in $moveClauses) { Write-Host " - $m" } Write-Host "" Write-Host "PreviewRestorePlan enabled. Exiting without executing restore operations." return } if ($shouldRenamePhysicalFiles) { Write-Host "Restoring FULL backup for $DbName as $restoreDbName..." } else { Write-Host "Restoring FULL backup for $DbName..." } Invoke-Sqlcmd -ServerInstance $Instance -Query $fullRestoreQuery -QueryTimeout 0 -ErrorAction Stop if ($selectedDiffKey) { $fileList = $selectedDiff.Files | ForEach-Object { "DISK = '$($_.FullName)'" } $restoreQuery = "RESTORE DATABASE [$restoreDbName] FROM $($fileList -join ', ') WITH NORECOVERY" Write-Host "Applying DIFF backup $selectedDiffKey for $restoreDbName..." Invoke-Sqlcmd -ServerInstance $Instance -Query $restoreQuery -QueryTimeout 0 -ErrorAction Stop } if ($logKeys.Count -gt 0) { for ($i = 0; $i -lt $logKeys.Count; $i++) { $key = $logKeys[$i] $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $fileList = $logFiles | ForEach-Object { "DISK = '$($_.FullName)'" } $logWithClause = "WITH NORECOVERY" # Apply STOPAT/STOPATMARK only on the final LOG restore statement. if ($isPointInTimeRestore -and $i -eq ($logKeys.Count - 1)) { if ($StopAtTime) { $escapedStopAtTime = $StopAtTime.Replace("'", "''") $logWithClause = "$logWithClause, STOPAT = '$escapedStopAtTime'" } elseif ($StopAtLSN) { $normalizedLsn = if ($StopAtLSN -like "lsn:*") { $StopAtLSN } else { "lsn:$StopAtLSN" } $escapedLsn = $normalizedLsn.Replace("'", "''") $logWithClause = "$logWithClause, STOPATMARK = '$escapedLsn'" } } $restoreQuery = "RESTORE LOG [$restoreDbName] FROM $($fileList -join ', ') $logWithClause" Write-Host "Applying LOG backup $key for $restoreDbName..." Invoke-Sqlcmd -ServerInstance $Instance -Query $restoreQuery -QueryTimeout 0 -ErrorAction Stop } } # Final recovery $restoreQuery = "RESTORE DATABASE [$restoreDbName] WITH RECOVERY" Write-Host "Finalizing restore for $restoreDbName..." Invoke-Sqlcmd -ServerInstance $Instance -Query $restoreQuery -QueryTimeout 0 -ErrorAction Stop Write-Host "Restore completed for $restoreDbName" } # Function to print backup summary function Print-BackupSummary { param([string]$DbName, [hashtable]$Headers) Write-Host "Backup Summary for database: $DbName" Write-Host "===================================" # FULL backups if ($Headers.ContainsKey('FULL')) { Write-Host "FULL Backups:" $fullItems = @($Headers['FULL'] | Sort-Object { $_.Key }) foreach ($item in $fullItems) { $header = @($item.Header)[0] if (-not $header) { continue } Write-Host " Date: $($header.BackupFinishDate) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" } } # DIFF backups if ($Headers.ContainsKey('DIFF')) { Write-Host "DIFFERENTIAL Backups:" $diffItems = @($Headers['DIFF'] | Sort-Object { $_.Key }) foreach ($item in $diffItems) { $header = @($item.Header)[0] if (-not $header) { continue } Write-Host " Date: $($header.BackupFinishDate) | Base LSN: $($header.DifferentialBaseLSN) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" } } # LOG backups if ($Headers.ContainsKey('LOG')) { $logItems = @($Headers['LOG'] | Sort-Object { $_.Key }) if ($logItems.Count -gt 0) { $firstLog = @($logItems[0].Header)[0] $lastLog = @($logItems[-1].Header)[0] Write-Host "LOG Backups:" Write-Host " Point-in-Time Range: $($firstLog.BackupStartDate) to $($lastLog.BackupFinishDate)" Write-Host " LSN Range: $($firstLog.FirstLSN) - $($lastLog.LastLSN)" # Check for gaps $gaps = @() for ($i = 1; $i -lt $logItems.Count; $i++) { $prevHeader = @($logItems[$i-1].Header)[0] $currHeader = @($logItems[$i].Header)[0] if (-not $prevHeader -or -not $currHeader) { continue } $prevLast = $prevHeader.LastLSN $currFirst = $currHeader.FirstLSN if ($prevLast -ne $currFirst) { $gaps += "Gap between $($logItems[$i-1].Key) (LSN $($prevLast)) and $($logItems[$i].Key) (LSN $($currFirst))" } } if ($gaps.Count -gt 0) { Write-Host " *** MISSING RANGES ***" foreach ($gap in $gaps) { Write-Host " $gap" } } else { Write-Host " No gaps detected in LOG sequence" } } } if (-not ($Headers.ContainsKey('FULL') -or $Headers.ContainsKey('DIFF') -or $Headers.ContainsKey('LOG'))) { Write-Host "No backup headers retrieved" } } # Function to verify backups function Verify-Backups { param([hashtable]$Catalog, [string]$Instance) foreach ($db in $catalog.Keys | Sort-Object) { Write-Host "Verifying backups for database: $db" $headers = @{} foreach ($type in $catalog[$db].Keys | Sort-Object) { foreach ($key in $catalog[$db][$type].Keys | Sort-Object) { $files = $catalog[$db][$type][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $fileList = $files | ForEach-Object { "DISK = '$($_.FullName)'" } $verifyQuery = "RESTORE VERIFYONLY FROM $($fileList -join ', ')" Write-Host "Verifying $type backup $key for $db..." $verified = $false try { Invoke-Sqlcmd -ServerInstance $Instance -Query $verifyQuery -QueryTimeout 0 -ErrorAction Stop Write-Host "Verification successful for $type $key" $verified = $true } catch { Write-Host "Verification failed for $type $key : $($_.Exception.Message)" } # Get backup header information only if verified if ($verified) { $header = Get-BackupInfo -Files $files -Instance $Instance if ($header) { Write-Host " Backup Details:" Write-Host " Start Date: $($header.BackupStartDate)" Write-Host " Finish Date: $($header.BackupFinishDate)" Write-Host " First LSN: $($header.FirstLSN)" Write-Host " Last LSN: $($header.LastLSN)" if ($header.DatabaseBackupLSN) { Write-Host " Database Backup LSN: $($header.DatabaseBackupLSN)" } if ($header.DifferentialBaseLSN) { Write-Host " Differential Base LSN: $($header.DifferentialBaseLSN)" } # Collect headers for summary if (-not $headers.ContainsKey($type)) { $headers[$type] = @() } $headers[$type] += @{ Key = $key; Header = $header } } } } } # Print summary Print-BackupSummary -DbName $db -Headers $headers Write-Host "" } } # Function to get backup header information function Get-BackupInfo { param([System.IO.FileInfo[]]$Files, [string]$Instance) $fileList = $Files | ForEach-Object { "DISK = '$($_.FullName)'" } $headerQuery = "RESTORE HEADERONLY FROM $($fileList -join ', ')" try { $header = Invoke-Sqlcmd -ServerInstance $Instance -Query $headerQuery -QueryTimeout 0 -ErrorAction Stop return $header } catch { Write-Warning "Failed to get header for $($Files[0].Name): $($_.Exception.Message)" return $null } } # Main script if ($Action -ne "restore" -and $NewName) { Write-Error "NewName is only valid with restore action" exit 1 } if ($StopAtTime -and $StopAtLSN) { Write-Error "Specify only one of StopAtTime or StopAtLSN" exit 1 } if ($Action -ne "restore" -and ($StopAtTime -or $StopAtLSN)) { Write-Error "StopAtTime and StopAtLSN are only valid with restore action" exit 1 } if ($Action -ne "restore" -and $PreviewRestorePlan) { Write-Error "PreviewRestorePlan is only valid with restore action" exit 1 } if ($Action -eq "catalog") { $catalog = Catalog-Backups -Path $LiveMountRoot if ($DatabaseName) { $filteredCatalog = @{} if ($catalog.ContainsKey($DatabaseName)) { $filteredCatalog[$DatabaseName] = $catalog[$DatabaseName] } Report-Catalog -Catalog $filteredCatalog } else { Report-Catalog -Catalog $catalog } } elseif ($Action -eq "restore") { if (-not $DatabaseName) { Write-Error "DatabaseName is required for restore action" exit 1 } if (-not $DataPath -or -not $LogPath) { Write-Error "DataPath and LogPath are required for restore action" exit 1 } $catalog = Catalog-Backups -Path $LiveMountRoot Restore-Database -DbName $DatabaseName -Catalog $catalog -Instance $SqlInstance -DataPath $DataPath -LogPath $LogPath -TargetDbName $NewName -StopAtTime $StopAtTime -StopAtLSN $StopAtLSN -PreviewRestorePlan $PreviewRestorePlan } elseif ($Action -eq "verify") { $catalog = Catalog-Backups -Path $LiveMountRoot if ($DatabaseName) { $filteredCatalog = @{} if ($catalog.ContainsKey($DatabaseName)) { $filteredCatalog[$DatabaseName] = $catalog[$DatabaseName] } } else { $filteredCatalog = $catalog } Verify-Backups -Catalog $filteredCatalog -Instance $SqlInstance }