convert to gitea
This commit is contained in:
402
Verify-SPOBackup.ps1
Normal file
402
Verify-SPOBackup.ps1
Normal file
@ -0,0 +1,402 @@
|
||||
# ==================================================
|
||||
# SharePoint 백업 검증 및 복구 스크립트 (최종판)
|
||||
# ==================================================
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
로컬 백업과 SharePoint Online 원본을 비교하고, 누락된 파일을 다운로드하여 복구.
|
||||
|
||||
.DESCRIPTION
|
||||
SharePoint Online 사이트와 로컬 백업 폴더를 스캔하여 파일 목록/크기 비교.
|
||||
검증 후, -DownloadMissingFiles 스위치를 사용하면 누락된 파일만 PoshRSJob을 통해
|
||||
병렬로 다운로드하여 백업 보완.
|
||||
|
||||
.PARAMETER SiteUrl
|
||||
검증 및 복구할 원본 SharePoint Online 사이트 URL.
|
||||
|
||||
.PARAMETER BackupPath
|
||||
(선택) 검증할 로컬 백업 폴더 경로. 지정하지 않으면 폴더 선택창이 팝업.
|
||||
|
||||
.PARAMETER DownloadMissingFiles
|
||||
(선택) 이 스위치를 사용하면, 검증 후 로컬에 누락된 파일을 서버에서 다운.
|
||||
**기존 백업시 다운로드 실패 파일이 존재해야함.
|
||||
|
||||
.PARAMETER ThrottleLimit
|
||||
(선택) 누락 파일 다운로드 시 병렬 처리 개수 (기본값 1). 대용량 파일 복구 시 1로 사용할 것!
|
||||
|
||||
.NOTES
|
||||
실행: PowerShell 7 이상 권장.
|
||||
모듈: Microsoft.Graph, PoshRSJob 필요.
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SiteUrl,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateScript({
|
||||
if (Test-Path $_ -PathType Container) {
|
||||
return $true
|
||||
}
|
||||
else {
|
||||
throw "지정한 경로 '$_'를 찾을 수 없거나 폴더가 아닙니다."
|
||||
}
|
||||
})]
|
||||
[string]$BackupPath,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$DownloadMissingFiles,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$ThrottleLimit = 1
|
||||
)
|
||||
|
||||
# ==================================================
|
||||
# region: 도우미 함수
|
||||
# ==================================================
|
||||
|
||||
function Select-FolderDialog {
|
||||
param(
|
||||
[string]$Title = "폴더 선택",
|
||||
[string]$InitialDirectory = [Environment]::GetFolderPath('Desktop')
|
||||
)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dlg.Description = $Title
|
||||
$dlg.SelectedPath = $InitialDirectory
|
||||
if ($dlg.ShowDialog((New-Object System.Windows.Forms.NativeWindow))) {
|
||||
return $dlg.SelectedPath
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return Read-Host "$Title"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-FileSize {
|
||||
param([long]$bytes)
|
||||
$suf = "B", "KB", "MB", "GB", "TB", "PB"
|
||||
if ($bytes -le 0) { return "0 B" }
|
||||
$i = [math]::Floor([math]::Log($bytes, 1024))
|
||||
if ($i -ge $suf.Length) { $i = $suf.Length - 1 }
|
||||
"{0:N2} {1}" -f ($bytes / [math]::Pow(1024, $i)), $suf[$i]
|
||||
}
|
||||
|
||||
function Sanitize-FileName {
|
||||
param([string]$FileName)
|
||||
$invalidChars = '[\\/:"*?<>|]'
|
||||
return ($FileName -replace $invalidChars, '_')
|
||||
}
|
||||
|
||||
function Get-AllFilesRecursive {
|
||||
param(
|
||||
[string]$DriveId,
|
||||
[string]$RootItemId
|
||||
)
|
||||
|
||||
$filesToScan = [System.Collections.Generic.List[object]]::new()
|
||||
$folderCount = 0
|
||||
$folderQueue = [System.Collections.Queue]::new()
|
||||
$folderQueue.Enqueue(@{ ItemId = $RootItemId; RelativePath = "" })
|
||||
|
||||
$consoleWidth = if ($Host.UI.RawUI) { $Host.UI.RawUI.WindowSize.Width } else { 80 }
|
||||
|
||||
while ($folderQueue.Count -gt 0) {
|
||||
$currentFolder = $folderQueue.Dequeue()
|
||||
if ([string]::IsNullOrWhiteSpace($currentFolder.ItemId)) { continue }
|
||||
|
||||
$originalRelativePath = $currentFolder.RelativePath
|
||||
if ($null -eq $originalRelativePath) { $originalRelativePath = "" }
|
||||
|
||||
$sanitizedRelativePath = ($originalRelativePath -split '[\\/]' | ForEach-Object { Sanitize-FileName -FileName $_ }) -join '\'
|
||||
|
||||
$statusText = "발견: 폴더 $folderCount 개, 파일 $($filesToScan.Count) 개"
|
||||
$pathText = "탐색 중: $originalRelativePath"
|
||||
$maxLength = $consoleWidth - $statusText.Length - 15
|
||||
if ($pathText.Length -gt $maxLength) { $pathText = "..." + $pathText.Substring($pathText.Length - $maxLength) }
|
||||
|
||||
Write-Progress -Activity "SharePoint 원본 스캔 중" -Status $statusText -CurrentOperation $pathText -Id 0
|
||||
|
||||
$items = @()
|
||||
try {
|
||||
$page = Get-MgDriveItemChild -DriveId $DriveId -DriveItemId $currentFolder.ItemId -PageSize 999 -ErrorAction Stop
|
||||
if ($null -ne $page) { $items += $page }
|
||||
|
||||
$next = $page.AdditionalProperties.'@odata.nextLink'
|
||||
while ($null -ne $next) {
|
||||
Write-Progress -Activity "SharePoint 원본 스캔 중" -Status $statusText -CurrentOperation "탐색 중 (페이징): $originalRelativePath" -Id 0
|
||||
$page = Get-MgDriveItemChild -Uri $next -ErrorAction Stop
|
||||
if ($null -ne $page) {
|
||||
$items += $page
|
||||
$next = $page.AdditionalProperties.'@odata.nextLink'
|
||||
}
|
||||
else {
|
||||
$next = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "폴더 '$originalRelativePath' 스캔 실패: $($_.Exception.Message)"
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($item in $items) {
|
||||
if (-not $item.Name) { continue }
|
||||
|
||||
$nextOriginalPath = if ([string]::IsNullOrEmpty($originalRelativePath)) { $item.Name } else { Join-Path $originalRelativePath $item.Name }
|
||||
|
||||
if ($null -ne $item.Folder.ChildCount) {
|
||||
$folderCount++
|
||||
$folderQueue.Enqueue(@{ ItemId = $item.Id; RelativePath = $nextOriginalPath })
|
||||
}
|
||||
elseif ($null -ne $item.File.MimeType -and $item.Size -gt 0) {
|
||||
$sanitizedFileName = Sanitize-FileName -FileName $item.Name
|
||||
$itemRelativePath = if ([string]::IsNullOrEmpty($sanitizedRelativePath)) { $sanitizedFileName } else { Join-Path $sanitizedRelativePath $sanitizedFileName }
|
||||
|
||||
$filesToScan.Add([PSCustomObject]@{
|
||||
RelativePath = $itemRelativePath
|
||||
Size = [long]$item.Size
|
||||
DownloadUrl = $item.AdditionalProperties.'@microsoft.graph.downloadUrl'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Files = $filesToScan
|
||||
FolderCount = $folderCount
|
||||
}
|
||||
}
|
||||
# endregion
|
||||
|
||||
# ==================================================
|
||||
# 메인 스크립트
|
||||
# ==================================================
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Host "--- SharePoint 백업 검증 스크립트 시작 ---" -ForegroundColor Yellow
|
||||
|
||||
# 1) 연결 및 설정
|
||||
Write-Host "`n[1/6] Microsoft Graph 연결 및 설정 확인..." -ForegroundColor Cyan
|
||||
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph)) {
|
||||
Write-Host "[ERROR] 'Microsoft.Graph' 모듈이 필요합니다." -ForegroundColor Red
|
||||
exit
|
||||
}
|
||||
if ($DownloadMissingFiles -and (-not (Get-Module -ListAvailable -Name PoshRSJob))) {
|
||||
Write-Host "[ERROR] 다운로드 기능을 사용하려면 'PoshRSJob' 모듈이 필요합니다. (Install-Module PoshRSJob)" -ForegroundColor Red
|
||||
exit
|
||||
}
|
||||
|
||||
$requiredScopes = "Sites.Read.All", "Files.Read.All"
|
||||
if (-not (Get-MgContext)) {
|
||||
Connect-MgGraph -Scopes $requiredScopes
|
||||
}
|
||||
Write-Host "✅ 연결 성공: $(Get-MgContext).Account" -ForegroundColor Green
|
||||
|
||||
# 2) 백업 경로 확인
|
||||
Write-Host "`n[2/6] 검증 대상 경로 확인..." -ForegroundColor Cyan
|
||||
if ([string]::IsNullOrWhiteSpace($BackupPath)) {
|
||||
$BackupPath = Select-FolderDialog -Title "검증할 로컬 백업 폴더를 선택하세요"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($BackupPath) -or -not (Test-Path $BackupPath -PathType Container)) {
|
||||
Write-Host "❌ 유효한 백업 경로가 지정되지 않았습니다. 스크립트를 종료합니다." -ForegroundColor Red
|
||||
exit
|
||||
}
|
||||
Write-Host "✅ 검증 대상 로컬 경로 확인: $BackupPath" -ForegroundColor Green
|
||||
|
||||
# 3) SharePoint 원본 스캔
|
||||
Write-Host "`n[3/6] SharePoint 원본 사이트 스캔 중..." -ForegroundColor Cyan
|
||||
$uri = [Uri]$SiteUrl
|
||||
$site = Get-MgSite -SiteId "$($uri.Host):$($uri.AbsolutePath)"
|
||||
$drive = Get-MgSiteDrive -SiteId $site.Id | Where-Object Name -in @("Documents", "문서") | Select-Object -First 1
|
||||
if (-not $drive) {
|
||||
Write-Host "❌ 문서 라이브러리 없음" -ForegroundColor Red
|
||||
exit
|
||||
}
|
||||
$spoScan = Get-AllFilesRecursive -DriveId $drive.Id -RootItemId 'root'
|
||||
Write-Progress -Id 0 -Completed
|
||||
$spoFiles = $spoScan.Files
|
||||
Write-Host "✅ SharePoint 스캔 완료: $($spoFiles.Count)개 파일 발견" -ForegroundColor Green
|
||||
|
||||
# 4) 로컬 백업 스캔
|
||||
Write-Host "`n[4/6] 로컬 백업 폴더 스캔 중..." -ForegroundColor Cyan
|
||||
$localFiles = Get-ChildItem -Path $BackupPath -Recurse -File | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
RelativePath = $_.FullName.Substring($BackupPath.Length + 1)
|
||||
Size = $_.Length
|
||||
}
|
||||
}
|
||||
Write-Host "✅ 로컬 스캔 완료: $($localFiles.Count)개 파일 발견" -ForegroundColor Green
|
||||
|
||||
# 5) 원본과 백업 비교
|
||||
Write-Host "`n[5/6] 원본과 백업 비교 및 검증 중..." -ForegroundColor Cyan
|
||||
$localFilesHashTable = @{}
|
||||
$localFiles | ForEach-Object { $localFilesHashTable[$_.RelativePath] = $_.Size }
|
||||
|
||||
$matchCount = 0
|
||||
$mismatchList = [System.Collections.Generic.List[object]]::new()
|
||||
$missingList = [System.Collections.Generic.List[object]]::new()
|
||||
$totalSpoFiles = $spoFiles.Count
|
||||
$progress = 0
|
||||
|
||||
foreach ($spoFile in $spoFiles) {
|
||||
$progress++
|
||||
Write-Progress -Activity "파일 비교 중" -Status "$progress / $totalSpoFiles" -PercentComplete (($progress / $totalSpoFiles) * 100) -Id 1
|
||||
|
||||
if ($localFilesHashTable.ContainsKey($spoFile.RelativePath)) {
|
||||
if ($spoFile.Size -eq $localFilesHashTable[$spoFile.RelativePath]) {
|
||||
$matchCount++
|
||||
}
|
||||
else {
|
||||
$mismatchList.Add([PSCustomObject]@{
|
||||
File = $spoFile.RelativePath
|
||||
SpoSize = (Format-FileSize $spoFile.Size)
|
||||
LocalSize = (Format-FileSize $localFilesHashTable[$spoFile.RelativePath])
|
||||
})
|
||||
}
|
||||
$localFilesHashTable.Remove($spoFile.RelativePath)
|
||||
}
|
||||
else {
|
||||
$missingList.Add($spoFile)
|
||||
}
|
||||
}
|
||||
Write-Progress -Id 1 -Completed
|
||||
|
||||
$extraList = $localFilesHashTable.GetEnumerator() | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
File = $_.Name
|
||||
Size = (Format-FileSize $_.Value)
|
||||
}
|
||||
}
|
||||
Write-Host "✅ 비교 완료!" -ForegroundColor Green
|
||||
|
||||
# 6) 누락된 파일 다운로드 (기능 추가)
|
||||
if ($DownloadMissingFiles -and $missingList.Count -gt 0) {
|
||||
Write-Host "`n[6/6] 누락된 파일 $($missingList.Count)개 다운로드 시작... (병렬 $ThrottleLimit)" -ForegroundColor Cyan
|
||||
|
||||
$downloadScriptBlock = {
|
||||
param($File, $BackupPath)
|
||||
try {
|
||||
$localPath = Join-Path $BackupPath $File.RelativePath
|
||||
$localDir = Split-Path -Path $localPath -Parent
|
||||
if (-not (Test-Path -LiteralPath $localDir)) {
|
||||
New-Item -Path $localDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
if (-not $File.DownloadUrl) { throw "DownloadUrl이 없어 다운로드 불가" }
|
||||
Invoke-WebRequest -Uri $File.DownloadUrl -OutFile $localPath -TimeoutSec 7200 -UseBasicParsing -ErrorAction Stop
|
||||
return [PSCustomObject]@{ Status = 'Success'; File = $File.RelativePath; Message = '다운로드 성공' }
|
||||
}
|
||||
catch {
|
||||
return [PSCustomObject]@{ Status = 'Failure'; File = $File.RelativePath; Message = $_.Exception.Message.Trim() }
|
||||
}
|
||||
}
|
||||
Get-RSJob | Remove-RSJob
|
||||
$jobs = $missingList | Start-RSJob -ScriptBlock $downloadScriptBlock -ArgumentList $BackupPath -Throttle $ThrottleLimit
|
||||
|
||||
$totalDownloads = $missingList.Count
|
||||
$completedCount = 0
|
||||
$downloadStartTime = Get-Date
|
||||
while ($completedCount -lt $totalDownloads) {
|
||||
$completedCount = ($jobs | Where-Object State -in 'Completed', 'Failed').Count
|
||||
$percent = [math]::Round(($completedCount / $totalDownloads) * 100)
|
||||
$elapsed = (Get-Date) - $downloadStartTime
|
||||
$statusText = "$percent% 완료 ($completedCount/$totalDownloads) | 경과: $($elapsed.ToString('hh\:mm\:ss'))"
|
||||
Write-Progress -Id 2 -Activity "누락 파일 다운로드 중" -Status $statusText -PercentComplete $percent
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
$downloadResults = $jobs | Wait-RSJob | Receive-RSJob
|
||||
Write-Progress -Id 2 -Completed
|
||||
|
||||
$downloadSuccessCount = ($downloadResults | Where-Object Status -eq 'Success').Count
|
||||
$downloadFailureCount = ($downloadResults | Where-Object Status -eq 'Failure').Count
|
||||
Write-Host "✅ 다운로드 완료! (성공: $downloadSuccessCount, 실패: $downloadFailureCount)" -ForegroundColor Green
|
||||
if ($downloadFailureCount -gt 0) {
|
||||
Write-Warning "일부 파일 다운로드에 실패했습니다. 상세 내용은 로그를 확인하세요."
|
||||
}
|
||||
}
|
||||
$stopwatch.Stop()
|
||||
|
||||
# 최종 보고서 생성
|
||||
$mismatchCount = $mismatchList.Count
|
||||
$missingCount = $missingList.Count
|
||||
$extraCount = $extraList.Count
|
||||
$isPerfect = ($mismatchCount -eq 0) -and ($missingCount -eq 0)
|
||||
|
||||
$report = New-Object System.Text.StringBuilder
|
||||
$null = $report.AppendLine("==================================================")
|
||||
$null = $report.AppendLine(" SharePoint 백업 검증 보고서")
|
||||
$null = $report.AppendLine("==================================================")
|
||||
$null = $report.AppendLine(" > 검증 일시 : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||||
$null = $report.AppendLine(" > 대상 사이트 : $SiteUrl")
|
||||
$null = $report.AppendLine(" > 로컬 경로 : $BackupPath")
|
||||
$null = $report.AppendLine(" > 총 소요 시간 : $($stopwatch.Elapsed.ToString('hh\:mm\:ss'))")
|
||||
$null = $report.AppendLine("--------------------------------------------------")
|
||||
$null = $report.AppendLine(" [ 검증 요약 ]")
|
||||
$null = $report.AppendLine(" - 원본 파일 (SharePoint) : $($spoFiles.Count) 개")
|
||||
$null = $report.AppendLine(" - 백업 파일 (로컬) : $($localFiles.Count) 개")
|
||||
$null = $report.AppendLine(" - ✅ 일치 : $matchCount 개")
|
||||
$null = $report.AppendLine(" - ❌ 크기 불일치 : $mismatchCount 개")
|
||||
$null = $report.AppendLine(" - ❌ 누락된 파일 : $missingCount 개")
|
||||
$null = $report.AppendLine(" - ℹ️ 추가된 파일 (로컬만) : $extraCount 개")
|
||||
$null = $report.AppendLine("--------------------------------------------------")
|
||||
|
||||
if ($isPerfect) {
|
||||
$null = $report.AppendLine(" [ 최종 판정: ✅ 완벽한 백업 ]")
|
||||
$null = $report.AppendLine(" 모든 원본 파일이 로컬 백업에 정확히 일치합니다.")
|
||||
}
|
||||
else {
|
||||
$null = $report.AppendLine(" [ 최종 판정: ❌ 불완전한 백업 ]")
|
||||
$null = $report.AppendLine(" 백업이 원본과 일치하지 않습니다. 아래 상세 내용을 확인하세요.")
|
||||
}
|
||||
$null = $report.AppendLine("==================================================")
|
||||
|
||||
if ($mismatchCount -gt 0) {
|
||||
$null = $report.AppendLine(" [ ❌ 크기 불일치 상세 (원본 vs 로컬) ]")
|
||||
foreach ($item in $mismatchList) {
|
||||
$null = $report.AppendLine(" - $($item.File) `t ($($item.SpoSize) vs $($item.LocalSize))")
|
||||
}
|
||||
$null = $report.AppendLine("==================================================")
|
||||
}
|
||||
|
||||
if ($missingCount -gt 0) {
|
||||
$null = $report.AppendLine(" [ ❌ 누락된 파일 상세 (로컬에 없음) ]")
|
||||
foreach ($item in $missingList) {
|
||||
$null = $report.AppendLine(" - $($item.RelativePath) `t ($(Format-FileSize $item.Size))")
|
||||
}
|
||||
$null = $report.AppendLine("==================================================")
|
||||
}
|
||||
|
||||
if ($extraCount -gt 0) {
|
||||
$null = $report.AppendLine(" [ ℹ️ 추가된 파일 상세 (로컬에만 존재) ]")
|
||||
foreach ($item in $extraList) {
|
||||
$null = $report.AppendLine(" - $($item.File) `t ($($item.Size))")
|
||||
}
|
||||
$null = $report.AppendLine("==================================================")
|
||||
}
|
||||
|
||||
if ($DownloadMissingFiles -and $missingList.Count -gt 0) {
|
||||
$null = $report.AppendLine(" [ 누락 파일 다운로드 결과 ]")
|
||||
$null = $report.AppendLine(" - ✅ 성공: $downloadSuccessCount 개, ❌ 실패: $downloadFailureCount 개")
|
||||
if ($downloadFailureCount -gt 0) {
|
||||
$null = $report.AppendLine(" [ 실패 항목 ]")
|
||||
($downloadResults | Where-Object Status -eq 'Failure') | ForEach-Object {
|
||||
$null = $report.AppendLine(" - $($_.File): $($_.Message)")
|
||||
}
|
||||
}
|
||||
$null = $report.AppendLine("==================================================")
|
||||
}
|
||||
|
||||
$finalReport = $report.ToString()
|
||||
$finalReport.Split([Environment]::NewLine) | ForEach-Object {
|
||||
if ($_ -like "*✅*") { Write-Host $_ -ForegroundColor Green }
|
||||
elseif ($_ -like "*❌*") { Write-Host $_ -ForegroundColor Red }
|
||||
elseif ($_ -like "*ℹ️*") { Write-Host $_ -ForegroundColor Yellow }
|
||||
else { Write-Host $_ }
|
||||
}
|
||||
|
||||
$logPath = Join-Path $BackupPath "verification_report_$(Get-Date -Format yyyyMMdd_HHmmss).log"
|
||||
$finalReport | Out-File -FilePath $logPath -Encoding UTF8
|
||||
|
||||
Write-Host "`n검증 보고서가 다음 위치에 저장되었습니다." -ForegroundColor Green
|
||||
Write-Host $logPath
|
||||
Reference in New Issue
Block a user