<# .SYNOPSIS 真实归档的恢复演练:把**硬盘上真实的归档**恢复到临时目标,再和活的源目录逐字节对拍。 .DESCRIPTION 和 tests/Run-E2E.ps1 的分工: * Run-E2E.ps1 用自己造的假数据,证明的是"整条链路能跑通"; * 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。 关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog), 把软件名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录, 而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。 对拍规则(关键:先把"源变了"和"归档坏了"分开): * 恢复树里每个文件都必须在活源里存在 —— 否则失败(说明归档里混进了别的东西); * 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过, 只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败; * 活源里在备份之后新增 / 删掉的文件只提示; * 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。 真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据; 上面对"源变了"的区分让这个演练在活的机器上也能天天跑。 .EXAMPLE # 用真实归档(默认读 BackupConfig.psd1 里的 BackupDir)做演练 pwsh -File .\tests\Restore-Drill.ps1 .EXAMPLE # 只演练指定条目,并保留下临时工作目录 pwsh -File .\tests\Restore-Drill.ps1 -Entries '.ssh','legendary' -KeepWorkRoot #> [CmdletBinding()] param( # 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir [string]$BackupDir, # 要演练的条目(软件名)。默认是一组"小、静态、无排除规则"的条目 [string[]]$Entries = @( '.ssh', 'legendary', 'scoop-config', 'opencode', 'PowerShell', 'WindowsPowerShell', 'MiFlash', 'MiFlash_Unlock', 'Startup', 'WindowsTerminal', 'Aria' ), [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'), [string]$WorkRoot, # 活源在备份之后变过的文件只告警、不算失败 [switch]$AllowChanged, [switch]$KeepWorkRoot ) $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot $restoreScript = Join-Path $projectRoot 'Restore.ps1' Import-Module (Join-Path $projectRoot 'Common.psm1') -Force if (-not (Test-Path -LiteralPath $restoreScript)) { Write-Error "找不到 Restore.ps1:$restoreScript" exit 1 } $config = Get-BaknretConfig -Path $ConfigPath $catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot if (-not $BackupDir) { $BackupDir = $config.BackupDir if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir } } if (-not (Test-Path -LiteralPath $BackupDir)) { Write-Error "归档目录不存在:$BackupDir" exit 1 } if (-not $WorkRoot) { $WorkRoot = Join-Path $env:TEMP ('baknret-drill-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) } New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null Write-Host '' Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan Write-Host " 归档目录:$BackupDir" Write-Host " 软件名录:$catalogPath" Write-Host " 工作目录:$WorkRoot" Write-Host '' # --------------------------------------------------------------------------- # 工具 # --------------------------------------------------------------------------- function Compare-RestoredTree { <# .SYNOPSIS 把恢复出来的树和活源逐字节对拍。 .DESCRIPTION 对拍结果分成两类,因为它们的含义完全不同: * Stale(活源在归档之后被改过):**只提示**。这是源变了,不是归档坏了 —— 真实机器上的归档往往是几周前的,硬按"必须和今天一致"判失败毫无意义。 * Changed(活源时间不晚于归档,内容却不一样):**失败**。这说明归档本身 或者解压环节有问题,是真正要查的。 #> param( [Parameter(Mandatory = $true)][string]$RestoredPath, [Parameter(Mandatory = $true)][string]$LivePath, [Parameter(Mandatory = $true)][datetime]$ArchiveTime ) $report = [pscustomobject]@{ Restored = 0 Matched = 0 Stale = @() Changed = @() Extra = @() Missing = @() } if (-not (Test-Path -LiteralPath $RestoredPath)) { throw "恢复目标不存在:$RestoredPath" } $liveItem = Get-Item -LiteralPath $LivePath -Force -ErrorAction Stop $restoredItem = Get-Item -LiteralPath $RestoredPath -Force -ErrorAction Stop # 源本身是个文件(例如 translucenttb 的 settings.json):直接比这一个 if (-not $liveItem.PSIsContainer) { if ($restoredItem.PSIsContainer) { throw "源是文件,恢复出来的却是目录:$RestoredPath" } $report.Restored = 1 if ((Get-FileHash -LiteralPath $restoredItem.FullName -Algorithm SHA256).Hash -eq (Get-FileHash -LiteralPath $liveItem.FullName -Algorithm SHA256).Hash) { $report.Matched = 1 } elseif ($liveItem.LastWriteTime -gt $ArchiveTime) { $report.Stale = @($restoredItem.Name) } else { $report.Changed = @($restoredItem.Name) } return $report } $restoredRoot = $restoredItem.FullName $liveRoot = $liveItem.FullName $restoredFiles = @(Get-ChildItem -LiteralPath $restoredRoot -Recurse -Force -File -ErrorAction SilentlyContinue) $report.Restored = $restoredFiles.Count foreach ($file in $restoredFiles) { $relative = $file.FullName.Substring($restoredRoot.Length).TrimStart('\') $liveFile = Join-Path $liveRoot $relative if (-not (Test-Path -LiteralPath $liveFile)) { $report.Extra += $relative continue } if ((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash -eq (Get-FileHash -LiteralPath $liveFile -Algorithm SHA256).Hash) { $report.Matched++ continue } # 内容不一样:先看是不是"源在归档之后动过" if ((Get-Item -LiteralPath $liveFile -Force).LastWriteTime -gt $ArchiveTime) { $report.Stale += $relative } else { $report.Changed += $relative } } foreach ($file in @(Get-ChildItem -LiteralPath $liveRoot -Recurse -Force -File -ErrorAction SilentlyContinue)) { $relative = $file.FullName.Substring($liveRoot.Length).TrimStart('\') if (-not (Test-Path -LiteralPath (Join-Path $restoredRoot $relative))) { $report.Missing += $relative } } return $report } # --------------------------------------------------------------------------- # 演练 # --------------------------------------------------------------------------- $rows = @() $failures = @() $checked = 0 foreach ($name in $Entries) { $entry = ConvertFrom-BackupListLine -Line $name if (-not $entry) { continue } $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth if (-not $resolved.BaseName) { $failures += "$name :解析不出归档名" $rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = '解析不出归档名' } continue } $archivePath = Join-Path $BackupDir ($resolved.BaseName + '.7z') if (-not (Test-Path -LiteralPath $archivePath)) { $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在:$($resolved.BaseName).7z" } continue } $archiveTime = (Get-Item -LiteralPath $archivePath).LastWriteTime $sources = @($resolved.Sources) if ($sources.Count -eq 0) { $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' } continue } if (@($sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }).Count -eq 0) { $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' } continue } $entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null # 每个源各自映射到一个临时目标:临时名录保持**同样的个数与顺序**, # 于是 Restore 会把第 i 个源还原到第 i 个临时目录,再和第 i 个活源逐字节对拍。 # (一个条目可以挂多个目录:软件名录的数组写法、以及清单里的 :+ 追加。) $scratchEntries = @() $pairs = @() for ($index = 0; $index -lt $sources.Count; $index++) { $source = $sources[$index] $leaf = @($source.RelativePaths)[0] $scratchTarget = Join-Path (Join-Path $entryRoot $index) $leaf $scratchEntries += $scratchTarget $pairs += [pscustomobject]@{ Restored = $scratchTarget Live = $source.SourcePath Exists = (Test-Path -LiteralPath $source.SourcePath) } } # 临时名录:把这些目录全指到临时目标,Restore 就解到这里,碰不到真实目录 $scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1") $scratchList = Join-Path $WorkRoot ("list-$name.txt") $scratchConfig = Join-Path $WorkRoot ("config-$name.psd1") $itemLines = @($scratchEntries | ForEach-Object { " @{ Path = '$_' }" }) -join "`n" [System.IO.File]::WriteAllText($scratchCatalog, "@{`n '$name' = @(`n$itemLines`n )`n}`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($scratchList, "$name`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($scratchConfig, @" @{ BackupDir = '$BackupDir' LogDir = '$(Join-Path $WorkRoot 'logs')' SoftwareCatalog = '$scratchCatalog' CatalogMaxDepth = $($config.CatalogMaxDepth) VerifyArchive = `$true } "@, [System.Text.UTF8Encoding]::new($false)) Write-Host ("-- 演练 {0}(归档 {1}.7z,{2} 个目录)" -f $name, $resolved.BaseName, $sources.Count) -ForegroundColor Gray # 用**子进程**跑 Restore.ps1:它结尾会 exit,子进程既不会打断演练, # 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。 $restoreExit = 0 $restoreOutput = @() try { $restoreOutput = & pwsh -NoProfile -NonInteractive -File $restoreScript ` -BackupListPath $scratchList -ConfigPath $scratchConfig -BackupDir $BackupDir -Force 2>&1 $restoreExit = $LASTEXITCODE } catch { $restoreExit = -1 Write-Host (" Restore.ps1 调用失败:$_") -ForegroundColor Red } if ($restoreExit -ne 0) { foreach ($line in @($restoreOutput | Select-Object -Last 12)) { Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray } $rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $restoreExit" } $failures += "$name :Restore.ps1 退出码 $restoreExit" continue } $checked++ $matched = 0 $restoredCount = 0 $extra = @() $stale = @() $changed = @() $notArchived = @() foreach ($pair in $pairs) { # 活源本来就没了的不对拍(归档里也不该有它) if (-not $pair.Exists) { continue } $one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime $matched += $one.Matched $restoredCount += $one.Restored $extra += $one.Extra $stale += $one.Stale $changed += $one.Changed $notArchived += $one.Missing } $detail = "对拍 $matched/$restoredCount" $status = 'PASS' if ($extra.Count -gt 0) { $status = 'FAIL' $detail += ";归档里有源里没有的 $($extra.Count) 个文件" $failures += "$name :恢复出源里没有的文件(首例 $($extra[0]))" } if ($stale.Count -gt 0) { # 活源在归档之后被改过:源变了,不是归档坏了,只提示 $detail += ";源在备份后变过 $($stale.Count) 个(不算失败)" } if ($changed.Count -gt 0) { if ($AllowChanged) { $detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)" } else { $status = 'FAIL' $detail += ";与活源不一致 $($changed.Count) 个(首例 $($changed[0]))" $failures += "$name :与活源不一致(首例 $($changed[0]))" } } if (($matched + $stale.Count) -eq 0) { $status = 'FAIL' $detail += ";没有任何文件能对上(多半是空归档)" $failures += "$name :恢复出 0 个可对拍的文件" } if ($notArchived.Count -gt 0) { # 排除规则命中的文件、以及备份之后新增的文件都会落在这里,只是提示 $detail += ";活源另有 $($notArchived.Count) 个文件不在归档里(排除规则/备份后新增)" } $rows += [pscustomobject]@{ Entry = $name; Status = $status; Detail = $detail } } # --------------------------------------------------------------------------- # 报告 # --------------------------------------------------------------------------- Write-Host '' Write-Host '演练结果:' -ForegroundColor Cyan $rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host if ($failures.Count -gt 0) { Write-Host '失败明细:' -ForegroundColor Red foreach ($failure in $failures) { Write-Host " - $failure" -ForegroundColor Red } } $passed = @($rows | Where-Object { $_.Status -eq 'PASS' }).Count $skipped = @($rows | Where-Object { $_.Status -eq 'SKIP' }).Count $failed = @($rows | Where-Object { $_.Status -eq 'FAIL' }).Count Write-Host ("恢复演练:通过 {0},跳过 {1},失败 {2}(真正对拍的条目 {3})" -f $passed, $skipped, $failed, $checked) -ForegroundColor $(if ($failed -gt 0) { 'Red' } else { 'Green' }) if ($KeepWorkRoot) { Write-Host "临时工作目录保留在:$WorkRoot" -ForegroundColor Yellow } else { Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue } if ($failed -gt 0) { exit 1 } exit 0