731 lines
29 KiB
PowerShell
731 lines
29 KiB
PowerShell
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 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: $(([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
|
|
} |