Files
BakNRet/tests/Restore-Drill.ps1
T
Shuery e114cae8c8 P0-P3 全量重构:退出码 / 解析修复、manifest 与 7z t 校验、干跑、排除规则、日志、测试与计划任务
P0 正确性
- 退出码:改用 .NET Process 直接启动、让子进程继承控制台,不再用 Start-Process -PassThru
  (在 7.7.0-preview.4 上 ExitCode 恒为 $null,会把成功的压缩判成失败);
  7z / RAR / tar 三条解压分支统一走同一个取退出码的封装。
- BackupList 解析:先按第一个 :: 切段再处理引号(整行被一对引号包住的写法不再把排除表
  吞进路径);排除表同时接受 , 与 ;(旧实现只认 ;,导致排除从未生效);支持 :- / :+ / @flag。
- 补回 .ssh 与孤儿归档:.ssh 进清单;孤儿归档在备份端也做审计并点名;
  带 -Only / -Skip 时不再把未选中的归档误报成孤儿。
- Resolve-BackupEntry 里 $rootName 在赋值前被引用(会读到外层作用域残留值),已提前赋值。

P1 归档可靠性
- 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因,
  并区分 warnings(在位归档)与 attemptWarnings(本次尝试)。
- 归档后做 7z t 内容校验,先写 .tmp、校验通过再原子替换(File.Move overwrite)。
- manifest.roots 记录归档内**真实**的顶层条目名(原先记的是软件名,Edge 实际是 "User Data")。

P2 可用性
- Restore 支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip;
  这三种"只看不写"的模式一个字节都不写(原先会写回 manifest.json)。
- Edge 等高缓存条目加排除规则并实测:1781 MB / 27961 项 -> 72 MB / 2294 项;
  书签、密码、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留。
  普通模式是相对归档根目录锚定的,嵌套的那些(如 OneAuth\WebView2 里的 Crashpad)
  改用 ! 组件形式才会命中。
- 日志落盘 logs/<backup|restore>-<时间戳>.log;退出码按失败数返回。
- tools/Register-BackupTask.ps1 注册每日计划任务;tools/Rename-Archives.ps1 迁移旧归档名。
- root= 标记此前静默失效,现在明确告警(该功能尚未实现)。

P3 测试与验证
- tests/BakNRet.Tests.ps1:Pester 5 套件 62 项(含用子进程跑 Backup.ps1 / Restore.ps1
  的端到端与针对上述缺陷的回归)。
- tests/Run-Pester.ps1 + tools/Install-TestDependencies.ps1:把 Pester 装到仓库内 .tools/,
  不动机器上的全局模块(系统自带的 3.4.0 缺 Should -Be)。
- tests/Restore-Drill.ps1:真实归档恢复演练,明确区分"源在备份后变过"与"归档/解压有问题"。
- tests/Run-Tests.ps1(49 项,零依赖)与 tests/Run-E2E.ps1(23 项)继续可用;三套共 134 项全通过。

真实机器验证
- 生产归档 22/22 通过 7z t;-VerifyOnly 不再改动 manifest.json(SHA256 前后一致)。
- 真实恢复演练 12/12 通过,27,670 个文件与活源逐字节一致。
- 修复了生产 scoop-persist.7z:原先只有 90 字节(空归档)而源有 1.3 GB,
  重打包后 233 MB,恢复演练 26981/26981 全部一致。
2026-09-21 23:02:47 +08:00

336 lines
14 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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.Count -gt 1) {
# 多目录条目恢复时会整包解压到每个位置,逐个对拍意义不大,默认不演练
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多目录条目($($sources.Count) 个源),不在演练范围内" }
continue
}
$liveSource = $sources[0].SourcePath
if (-not (Test-Path -LiteralPath $liveSource)) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "活源不存在,无法对拍:$liveSource" }
continue
}
$leaf = Split-Path -Path $liveSource -Leaf
$restoreParent = Join-Path (Join-Path $WorkRoot 'restore') $name
$scratchTarget = Join-Path $restoreParent $leaf
New-Item -ItemType Directory -Path $restoreParent -Force | Out-Null
# 临时名录:把这个软件名指到临时目标,Restore 就会解到这里,碰不到真实目录
$scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1")
$scratchList = Join-Path $WorkRoot ("list-$name.txt")
$scratchConfig = Join-Path $WorkRoot ("config-$name.psd1")
[System.IO.File]::WriteAllText($scratchCatalog, "@{`n '$name' = '$scratchTarget'`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)" -f $name, $resolved.BaseName) -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++
$report = Compare-RestoredTree -RestoredPath $scratchTarget -LivePath $liveSource -ArchiveTime $archiveTime
$detail = "对拍 $($report.Matched)/$($report.Restored)"
$status = 'PASS'
if ($report.Extra.Count -gt 0) {
$status = 'FAIL'
$detail += ";归档里有源里没有的 $($report.Extra.Count) 个文件"
$failures += "$name :恢复出源里没有的文件(首例 $($report.Extra[0]))"
}
if ($report.Stale.Count -gt 0) {
# 活源在归档之后被改过:源变了,不是归档坏了,只提示
$detail += ";源在备份后变过 $($report.Stale.Count) 个(不算失败)"
}
if ($report.Changed.Count -gt 0) {
if ($AllowChanged) {
$detail += ";与活源不一致 $($report.Changed.Count) 个(-AllowChanged,已容忍)"
} else {
$status = 'FAIL'
$detail += ";与活源不一致 $($report.Changed.Count) 个(首例 $($report.Changed[0]))"
$failures += "$name :与活源不一致(首例 $($report.Changed[0]))"
}
}
if (($report.Matched + $report.Stale.Count) -eq 0) {
$status = 'FAIL'
$detail += ";没有任何文件能对上(多半是空归档)"
$failures += "$name :恢复出 0 个可对拍的文件"
}
if ($report.Missing.Count -gt 0) {
# 排除规则命中的文件、以及备份之后新增的文件都会落在这里,只是提示
$detail += ";活源另有 $($report.Missing.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