param( [Parameter(Mandatory=$true)] [string]$LiveMountRoot, [Parameter(Mandatory=$false)] [string]$DatabaseName, [Parameter(Mandatory=$true)] [ValidateSet("catalog", "restore", "verify")] [string]$Action, [Parameter(Mandatory=$false)] [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) { $stripeVal = 0 if (-not [int]::TryParse($parts[$typeIndex + 3], [ref]$stripeVal)) { continue } $stripe = $stripeVal } $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 { # Parse using the current OS locale so users can enter dates in their own format. $targetStopAtTime = [datetime]::Parse($StopAtTime, [System.Globalization.CultureInfo]::CurrentCulture) } catch { Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value (expected format for your locale: $([System.Globalization.CultureInfo]::CurrentCulture.DateTimeFormat.ShortDatePattern) HH:mm:ss)" 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 = if ($fullHeader.CheckpointLSN) { [decimal]$fullHeader.CheckpointLSN } else { [decimal]$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 ([decimal]$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 = @() # Determine the LastLSN of the last backup that will be restored (FULL or DIFF). $lastRestoredHeader = if ($selectedDiff) { Get-BackupInfo -Files $selectedDiff.Files -Instance $Instance } else { $fullHeader } $lastRestoredLsn = if ($lastRestoredHeader) { [decimal]$lastRestoredHeader.LastLSN } else { $null } # Select LOG backups after the latest restored FULL/DIFF point. if ($dbCatalog.ContainsKey('LOG')) { $candidateLogKeys = @($dbCatalog['LOG'].Keys | Where-Object { $_ -gt $logStartKey } | Sort-Object) # Validate LSN continuity: the first candidate LOG must connect to the end of the FULL/DIFF chain. # If there is a gap (first LOG's FirstLSN > FULL/DIFF LastLSN), no logs can be applied. if ($candidateLogKeys.Count -gt 0 -and $lastRestoredLsn) { $firstLogFiles = $dbCatalog['LOG'][$candidateLogKeys[0]] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } $firstLogHeader = Get-BackupInfo -Files $firstLogFiles -Instance $Instance if ($firstLogHeader -and [decimal]$firstLogHeader.FirstLSN -gt $lastRestoredLsn) { Write-Warning "LSN gap detected: first available LOG backup ($($candidateLogKeys[0])) starts at LSN $($firstLogHeader.FirstLSN), but the restored FULL/DIFF ends at LSN $lastRestoredLsn. No log backups can be applied." $candidateLogKeys = @() } } $skipLogSelection = $false if ($isPointInTimeRestore -and $candidateLogKeys.Count -eq 0) { # Only error if the target point is beyond the last restored backup's finish time. # If the target is at or before that time, no logs are required and restore can proceed. $lastRestoredFinish = if ($lastRestoredHeader) { try { [datetime]$lastRestoredHeader.BackupFinishDate } catch { $null } } else { $null } $targetIsBeyondRestored = $true if ($targetStopAtTime -and $lastRestoredFinish -and $targetStopAtTime -le $lastRestoredFinish) { $targetIsBeyondRestored = $false } if ($targetStopAtLsn -and $lastRestoredLsn -and $targetStopAtLsn -le $lastRestoredLsn) { $targetIsBeyondRestored = $false } if ($targetIsBeyondRestored) { Write-Error "Point-in-time restore requested but no LOG backups are available after the selected FULL/DIFF restore point" return } # Target is within the FULL/DIFF range; no logs needed. $skipLogSelection = $true } if (-not $skipLogSelection -and $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 (-not $skipLogSelection -and $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 (-not $skipLogSelection -and $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 '$($targetStopAtTime.ToString([System.Globalization.CultureInfo]::CurrentCulture))'" } 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) { # Use the parsed datetime formatted as ISO 8601, not the raw input string. # SQL Server requires a consistent datetime format; the user's input may be # locale-specific (e.g. dd/MM/yyyy) which SQL Server does not accept. $sqlStopAt = $targetStopAtTime.ToString("yyyy-MM-dd HH:mm:ss") $logWithClause = "$logWithClause, STOPAT = '$sqlStopAt'" } 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: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) | 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: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) | 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: $(([datetime]$firstLog.BackupStartDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) to $(([datetime]$lastLog.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" Write-Host " LSN Range: $($firstLog.FirstLSN) - $($lastLog.LastLSN)" # Check that the first LOG connects to the most recent FULL (or DIFF if one exists). $baseLastLsn = $null $baseLabel = $null if ($Headers.ContainsKey('DIFF') -and $Headers['DIFF'].Count -gt 0) { $latestDiff = @($Headers['DIFF'] | Sort-Object { $_.Key })[-1] $dh = @($latestDiff.Header)[0] if ($dh) { $baseLastLsn = $dh.LastLSN; $baseLabel = "DIFF $($latestDiff.Key)" } } if (-not $baseLastLsn -and $Headers.ContainsKey('FULL') -and $Headers['FULL'].Count -gt 0) { $latestFull = @($Headers['FULL'] | Sort-Object { $_.Key })[-1] $fh = @($latestFull.Header)[0] if ($fh) { $baseLastLsn = $fh.LastLSN; $baseLabel = "FULL $($latestFull.Key)" } } if ($baseLastLsn -and [decimal]$firstLog.FirstLSN -gt [decimal]$baseLastLsn) { Write-Host " *** LSN GAP: first LOG (LSN $($firstLog.FirstLSN)) does not connect to $baseLabel (ends LSN $baseLastLsn) — point-in-time restore will fail ***" } # Check for gaps within the LOG sequence $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: $(([datetime]$header.BackupStartDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" Write-Host " Finish Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" 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 }