Данный PowerShell-скрипт (требуется версия 7+) позволяет автоматизировать процесс восстановления списка баз данных на целевой SQL Server из последних полных бэкапов, расположенных на сетевом ресурсе. Скрипт:
-
параллельно (с ограничением по числу потоков) обрабатывает базы, используя модуль dbatools;
-
автоматически удаляет существующие базы перед восстановлением (с опцией
WithReplace); -
динамически определяет файлы данных и логов из бэкапа и переносит их в заданные каталоги (
DataPath,LogPath) с генерацией уникальных имён при конфликтах; -
ведёт подробный журнал выполнения (
RestoreLog_*.log) и транскрипт всей сессии (RestoreTranscript_*.log) в папкеC:\temp; -
очищает старые логи (старше заданного числа дней, по умолчанию 14) – автоматически удаляет файлы логов, чтобы папка не разрасталась;
-
формирует HTML-отчёт с итогами (статус, длительность, детали) и отправляет его по электронной почте через SMTP (настраивается).
Все настраиваемые параметры (путь к бэкапам, целевой сервер, каталоги, список БД, почтовые настройки, срок хранения логов) вынесены в начало скрипта для удобства.
Требования:
-
PowerShell 7+ (из-за использования
ForEach-Object -Parallel); -
установленный модуль
dbatools(рекомендуется для всех пользователей); -
доступ к сетевой папке с бэкапами и целевому SQL Server;
-
права на создание/удаление баз и запись в каталоги данных/логов.
Скрипт предназначен для использования в планировщике задач Windows (Task Scheduler) с указанием pwsh.exe и возвращает код выхода (0 – успех, 1 – были ошибки) для контроля выполнения.
# ======================================================================
# Автоматическое восстановление баз данных с уведомлениями по email
# ======================================================================
# Требуется: PowerShell 7+ (pwsh.exe) из-за ForEach-Object -Parallel!
# Требуется: модуль dbatools, доступ к бэкапам и целевому SQL Server
# ======================================================================
# === НАСТРАИВАЕМЫЕ ПАРАМЕТРЫ ===
# Путь к бэкапам (сетевой ресурс)
$SourceBackupPath = "\\arc01.adminbd.ru\sql_backup$\MSK-DBCL01`$msk-dbag01"
# Целевой SQL Server
$TargetServer = "dev-db04"
# Каталоги для данных и логов
$DataPath = "e:\data"
$LogPath = "E:\log"
# Список баз для восстановления
$DatabaseList = @(
"Agent"
)
# Настройки email-уведомлений
$EmailFrom = "dev-@tech.adminbd.ru"
$EmailTo = @("mmers@adminbd.ru", "moskvichev@adminbd.ru")
$SmtpServer = "tech.adminbd.ru"
$SmtpPort = 25
# Срок хранения логов (дней) – файлы старше этого срока будут удалены
$LogRetentionDays = 14
# --- САМОЕ ПЕРВОЕ: гарантируем, что лог создаётся даже если всё остальное упадёт ---
$ErrorActionPreference = 'Stop'
$LogFilePath = "C:\temp\RestoreLog_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
$TranscriptPath = "C:\temp\RestoreTranscript_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
# Создаём папку для логов, если её нет
if (-not (Test-Path "C:\temp")) {
New-Item -ItemType Directory -Path "C:\temp" -Force | Out-Null
}
# --- ОЧИСТКА СТАРЫХ ЛОГОВ ---
try {
$OldLogs = Get-ChildItem -Path "C:\temp" -Include "RestoreLog_*.log", "RestoreTranscript_*.log" -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$LogRetentionDays) }
if ($OldLogs) {
$OldLogs | Remove-Item -Force
# Пишем в отдельный лог-файл, но он ещё не создан, поэтому используем Write-Host и потом допишем в основной лог после его создания
$CleanupMsg = "Удалено $($OldLogs.Count) старых лог-файлов (старше $LogRetentionDays дней)."
Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [INFO] $CleanupMsg"
# Сохраним сообщение для последующей записи в лог
$Global:CleanupMessage = $CleanupMsg
} else {
$Global:CleanupMessage = "Старых логов (старше $LogRetentionDays дней) не найдено."
Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [INFO] $Global:CleanupMessage"
}
} catch {
Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [WARN] Не удалось выполнить очистку логов: $($_.Exception.Message)"
$Global:CleanupMessage = "Ошибка очистки логов: $($_.Exception.Message)"
}
# Функция записи в лог (будет использовать $LogFilePath)
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$LogEntry = "[$Timestamp] [$Level] $Message"
Add-Content -Path $LogFilePath -Value $LogEntry -Encoding UTF8
Write-Host $LogEntry
}
# Запускаем транскрипт (записывает весь вывод сессии)
try {
Start-Transcript -Path $TranscriptPath -Force | Out-Null
} catch {
# Если не удалось, пишем в лог, но транскрипт не критичен
Write-Log "Не удалось запустить Start-Transcript: $($_.Exception.Message)" "WARN"
}
# Теперь записываем сообщение об очистке в основной лог
Write-Log $Global:CleanupMessage
# --- Диагностика окружения запуска ---
Write-Log "=========================================="
Write-Log "ЗАПУСК СКРИПТА"
Write-Log "=========================================="
Write-Log "PowerShell версия: $($PSVersionTable.PSVersion)"
Write-Log "PowerShell Edition: $($PSVersionTable.PSEdition)"
Write-Log "Host: $($Host.Name)"
Write-Log "Исполняемый файл: $([System.Diagnostics.Process]::GetCurrentProcess().Path)"
Write-Log "Текущий пользователь: $($env:USERDOMAIN)\$($env:USERNAME)"
Write-Log "Identity (whoami): $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Log "Компьютер: $env:COMPUTERNAME"
Write-Log "Рабочая директория (PWD): $($PWD.Path)"
Write-Log "Script Root: $PSScriptRoot"
Write-Log "Execution Policy: $(Get-ExecutionPolicy)"
Write-Log "Аргументы запуска: $($MyInvocation.Line)"
# Проверка версии PowerShell
if ($PSVersionTable.PSVersion.Major -lt 7) {
Write-Log "КРИТИЧЕСКАЯ ОШИБКА: обнаружен PowerShell $($PSVersionTable.PSVersion.Major).x. Скрипт использует ForEach-Object -Parallel и требует PowerShell 7+ (pwsh.exe). Проверьте, что в действии Task Scheduler указан путь к pwsh.exe, а не powershell.exe." "FATAL"
Stop-Transcript | Out-Null
exit 1
}
# Проверка модуля dbatools
try {
Write-Log "Проверка доступности модуля dbatools..."
$DbaModule = Get-Module -ListAvailable -Name dbatools -ErrorAction Stop
if (-not $DbaModule) {
throw "Модуль dbatools не найден в доступных путях модулей для текущей учётной записи."
}
Write-Log "Найден dbatools версии: $($DbaModule[0].Version), путь: $($DbaModule[0].ModuleBase)"
$AlreadyLoaded = Get-Module -Name dbatools
if ($AlreadyLoaded) {
Write-Log "Модуль dbatools уже загружен в текущей сессии (версия $($AlreadyLoaded.Version)) — повторный импорт пропущен."
} else {
Import-Module dbatools -ErrorAction SilentlyContinue -WarningAction SilentlyContinue 3>$null 2>$null
$LoadedCheck = Get-Module -Name dbatools
if (-not $LoadedCheck) {
throw "Import-Module не смог загрузить dbatools (см. подробности в C:\temp transcript)."
}
Write-Log "Модуль dbatools успешно импортирован."
}
} catch {
Write-Log "КРИТИЧЕСКАЯ ОШИБКА при работе с модулем dbatools: $($_.Exception.Message)" "FATAL"
Write-Log "Совет: если скрипт бежит под сервисной учёткой/SYSTEM, модуль нужно ставить с -Scope AllUsers, а не CurrentUser." "FATAL"
Stop-Transcript | Out-Null
exit 1
}
# Проверка доступности пути с бэкапами
try {
Write-Log "Проверка доступности пути к бэкапам: $SourceBackupPath"
if (-not (Test-Path -Path $SourceBackupPath)) {
throw "Путь недоступен для текущей учётной записи."
}
Write-Log "Путь к бэкапам доступен."
} catch {
Write-Log "КРИТИЧЕСКАЯ ОШИБКА: не удалось получить доступ к $SourceBackupPath : $($_.Exception.Message)" "FATAL"
Write-Log "Совет: проверьте права учётной записи, под которой запускается задача в Task Scheduler, на сетевой share." "FATAL"
Stop-Transcript | Out-Null
exit 1
}
# Проверка связи с целевым сервером
try {
Write-Log "Проверка подключения к $TargetServer..."
$TestConn = Test-DbaConnection -SqlInstance $TargetServer -ErrorAction Stop
Write-Log "Подключение к $TargetServer успешно."
} catch {
Write-Log "КРИТИЧЕСКАЯ ОШИБКА: не удалось подключиться к $TargetServer : $($_.Exception.Message)" "FATAL"
Stop-Transcript | Out-Null
exit 1
}
# === ФУНКЦИЯ ОТПРАВКИ EMAIL ===
function Send-Email {
param(
[string]$Subject,
[string]$Body,
[switch]$IsHtml
)
try {
$Params = @{
SmtpServer = $SmtpServer
Port = $SmtpPort
From = $EmailFrom
To = $EmailTo
Subject = $Subject
Body = $Body
Encoding = [System.Text.Encoding]::UTF8
DeliveryNotificationOption = 'Never'
}
if ($IsHtml) { $Params.BodyAsHtml = $true }
Send-MailMessage @Params
Write-Log "Письмо отправлено: $Subject"
}
catch {
Write-Log "Ошибка отправки email: $($_.Exception.Message)" "ERROR"
}
}
Write-Log "=== ЗАПУСК восстановления баз данных на $TargetServer ==="
# Список для результатов
$Results = [System.Collections.Generic.List[object]]::new()
# === Параллельное восстановление ===
try {
$ParallelResults = $DatabaseList | ForEach-Object -Parallel {
$DatabaseName = $_
$SourceBackupPath = $using:SourceBackupPath
$TargetServer = $using:TargetServer
$DataPath = $using:DataPath
$LogPath = $using:LogPath
$LogFilePath = $using:LogFilePath
# Подключаем модуль в потоке
try {
Import-Module dbatools -ErrorAction Stop
} catch {
Add-Content -Path $LogFilePath -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [FATAL] [$DatabaseName] Не удалось импортировать dbatools в параллельном потоке: $($_.Exception.Message)" -Encoding UTF8
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Ошибка"
Duration = "N/A"
Details = "Ошибка импорта dbatools в потоке: $($_.Exception.Message)"
}
}
# Локальная лог-функция для потока
$Log = {
param($msg, $level = "INFO")
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $LogFilePath -Value "[$ts] [$level] [$DatabaseName] $msg" -Encoding UTF8
Write-Host "[$ts] [$level] [$DatabaseName] $msg"
}.GetNewClosure()
& $Log "→ Начало обработки базы (PID потока: $PID)"
# Удаление существующей базы
$DbExists = Get-DbaDatabase -SqlInstance $TargetServer -Database $DatabaseName -ErrorAction SilentlyContinue
if ($DbExists) {
try {
& $Log "Удаление существующей базы..."
$DropResult = Remove-DbaDatabase -SqlInstance $TargetServer -Database $DatabaseName -Confirm:$false -EnableException
if ($DropResult.Status -ne "Dropped") {
throw "Неожиданный статус при удалении: $($DropResult.Status)"
}
& $Log "База успешно удалена."
}
catch {
& $Log "Ошибка удаления базы: $($_.Exception.Message)" "ERROR"
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Ошибка"
Duration = "N/A"
Details = "Ошибка удаления: $($_.Exception.Message)"
}
}
} else {
& $Log "База не существует — удаление пропущено."
}
# Поиск последнего FULL-бэкапа
$FullBackupDir = Join-Path $SourceBackupPath (Join-Path $DatabaseName "FULL")
& $Log "Поиск бэкапов в: $FullBackupDir"
$BackupFiles = Get-ChildItem -Path $FullBackupDir -Filter "*.bak" -ErrorAction SilentlyContinue | Sort-Object CreationTime -Descending
if (-not $BackupFiles -or $BackupFiles.Count -eq 0) {
& $Log "Бэкап не найден в: $FullBackupDir" "ERROR"
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Ошибка"
Duration = "N/A"
Details = "Бэкап не найден"
}
}
$LatestBackup = $BackupFiles[0].FullName
& $Log "Найдено файлов бэкапа: $($BackupFiles.Count). Используется: $LatestBackup (создан: $($BackupFiles[0].CreationTime))"
# Генерация FileMapping через скрипт
try {
& $Log "Генерация скрипта восстановления (OutputScriptOnly)..."
$ScriptText = Restore-DbaDatabase -SqlInstance $TargetServer -Path $LatestBackup -DatabaseName $DatabaseName -OutputScriptOnly -EnableException
}
catch {
& $Log "Ошибка генерации скрипта восстановления: $($_.Exception.Message)" "ERROR"
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Ошибка"
Duration = "N/A"
Details = "Ошибка генерации скрипта: $($_.Exception.Message)"
}
}
# Парсинг MOVE-инструкций
$MoveRegex = [regex]::new("MOVE N'([^']+)' TO N'([^']+)'")
$Matches = $MoveRegex.Matches($ScriptText)
if ($Matches.Count -eq 0) {
& $Log "Не найдено ни одной инструкции MOVE в скрипте восстановления." "ERROR"
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Ошибка"
Duration = "N/A"
Details = "Не удалось определить файлы из бэкапа"
}
}
$NewFileMap = @{}
$UsedPaths = @{}
foreach ($Match in $Matches) {
$LogicalName = $Match.Groups[1].Value
$OriginalPath = $Match.Groups[2].Value
$Extension = [IO.Path]::GetExtension($OriginalPath)
$BaseName = "${DatabaseName}_${LogicalName}"
$TargetDir = if ($Extension -eq '.ldf') { $LogPath } else { $DataPath }
$NewPath = Join-Path $TargetDir "$BaseName$Extension"
$Counter = 1
$FinalPath = $NewPath
while ($UsedPaths.ContainsKey($FinalPath)) {
$FinalPath = Join-Path $TargetDir "${BaseName}_${Counter}${Extension}"
$Counter++
}
$UsedPaths[$FinalPath] = $true
$NewFileMap[$LogicalName] = $FinalPath
& $Log " MOVE: $LogicalName -> $FinalPath"
}
& $Log "Создана FileMapping (файлов: $($NewFileMap.Count))"
# Восстановление
$StartTime = Get-Date
try {
& $Log "▶ Запуск восстановления..."
$null = Restore-DbaDatabase -SqlInstance $TargetServer -Path $LatestBackup -DatabaseName $DatabaseName -FileMapping $NewFileMap -WithReplace -EnableException
$EndTime = Get-Date
$Duration = $EndTime - $StartTime
$DurationStr = "{0:hh\:mm\:ss}" -f $Duration
$DbCheck = Get-DbaDatabase -SqlInstance $TargetServer -Database $DatabaseName -ErrorAction SilentlyContinue
if ($DbCheck) {
& $Log "✅ Успешно восстановлена за $DurationStr"
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Успешно"
Duration = $DurationStr
Details = "Восстановлено за $DurationStr"
}
} else {
throw "База не обнаружена после восстановления"
}
}
catch {
$EndTime = Get-Date
$Duration = $EndTime - $StartTime
$DurationStr = "{0:hh\:mm\:ss}" -f $Duration
& $Log "Ошибка восстановления: $($_.Exception.Message)" "ERROR"
return [PSCustomObject]@{
DatabaseName = $DatabaseName
Status = "Ошибка"
Duration = $DurationStr
Details = "Ошибка: $($_.Exception.Message)"
}
}
} -ThrottleLimit 3
}
catch {
Write-Log "КРИТИЧЕСКАЯ ОШИБКА при выполнении параллельного блока: $($_.Exception.Message)" "FATAL"
Write-Log "StackTrace: $($_.ScriptStackTrace)" "FATAL"
Stop-Transcript | Out-Null
exit 1
}
foreach ($res in $ParallelResults) {
$Results.Add($res)
}
Write-Log "=== ВСЕ БАЗЫ ОБРАБОТАНЫ ==="
# === Формирование и отправка итогового отчёта ===
$HtmlRows = foreach ($r in $Results) {
$BgColor = if ($r.Status -eq "Успешно") { "#d4edda" } else { "#f8d7da" }
$Color = if ($r.Status -eq "Успешно") { "#155724" } else { "#721c24" }
"<tr style='background-color: $BgColor; color: $Color;'>
<td><strong>$($r.DatabaseName)</strong></td>
<td>$($r.Status)</td>
<td>$($r.Duration)</td>
<td>$($r.Details)</td>
</tr>"
}
$TotalCount = $Results.Count
$SuccessCount = ($Results | Where-Object Status -eq "Успешно").Count
$FailCount = $TotalCount - $SuccessCount
$Summary = if ($FailCount -eq 0) {
"✅ Все базы восстановлены успешно."
} else {
"⚠️ Успешно: $SuccessCount из $TotalCount. Ошибок: $FailCount."
}
Write-Log "ИТОГ: $Summary"
$CompletionTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$HtmlReport = @"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Отчёт: восстановление БД на $TargetServer</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; }
th, td { border: 1px solid #ccc; padding: 10px; text-align: left; }
th { background-color: #f1f1f1; }
.summary { font-size: 1.1em; font-weight: bold; margin-bottom: 15px; color: #333; }
</style>
</head>
<body>
<h2>✅ Восстановление баз данных на $TargetServer завершено</h2>
<p><strong>Время завершения:</strong> $CompletionTime</p>
<div class="summary">$Summary</div>
<table>
<thead>
<tr>
<th>База данных</th>
<th>Статус</th>
<th>Длительность</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
$($HtmlRows -join "")
</tbody>
</table>
<p><em>С уважением,<br>Скрипт автоматического восстановления</em></p>
</body>
</html>
"@
$EndSubject = if ($FailCount -eq 0) {
"✅ Успешно: восстановление БД на $TargetServer"
} else {
"⚠️ Частичный успех: восстановление БД на $TargetServer ($SuccessCount/$TotalCount)"
}
# Отправка письма (если заполнены настройки)
if ($EmailFrom -and $EmailTo -and $SmtpServer) {
Send-Email -Subject $EndSubject -Body $HtmlReport -IsHtml
} else {
Write-Log "Настройки email не заданы – письмо не отправлено." "WARN"
}
# === Вывод в консоль ===
Write-Host "`n$('=' * 60)" -ForegroundColor Cyan
Write-Host "РЕЗУЛЬТАТЫ ВОССТАНОВЛЕНИЯ" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
$Results | Format-Table -AutoSize
Write-Log "Лог: $LogFilePath"
Write-Log "Transcript: $TranscriptPath"
Stop-Transcript | Out-Null
# Возвращаем код выхода для Task Scheduler: 0 = всё ок, 1 = были ошибки
if ($FailCount -gt 0) {
exit 1
} else {
exit 0
}
Similar Posts:
- Как восстановить базы данных SQL Server автоматически с уведомлениями по email с помощью PowerShell
- Как сделать скрипт для восстановления баз данных из шары.
- Как вывести список пользователей у которых не при монтировался диск fslogix и переименовать FriendlyName
- Как копировать данных между SQL Server с фильтром по дате при помощи PowerShell и dbatools
- Как сбросить все сессии в статусе дисконект (disconnect) с фермы rds windows