Перейти к содержимому

Как сделать автоматическое восстановление нескольких баз данных SQL Server из сетевых бэкапов с уведомлениями по email и очисткой старых логов

Данный PowerShell-скрипт (требуется версия 7+) позволяет автоматизировать процесс восстановления списка баз данных на целевой SQL Server из последних полных бэкапов, расположенных на сетевом ресурсе. Скрипт:

  • параллельно (с ограничением по числу потоков) обрабатывает базы, используя модуль dbatools;

  • автоматически удаляет существующие базы перед восстановлением (с опцией WithReplace);

  • динамически определяет файлы данных и логов из бэкапа и переносит их в заданные каталоги (DataPathLogPath) с генерацией уникальных имён при конфликтах;

  • ведёт подробный журнал выполнения (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:

Добавить комментарий

Яндекс.Метрика