Files
BakNRet/tests/Restore-Drill.ps1
T
Shuery 43fa4e52dd 清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。

实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
  * 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
    也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
  * 目录说明(Description)一路带到运行日志里。
  * "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
  * scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
    ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
  * `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
    现在统一生效,且 :+ 后面写软件名会按名录展开。
  * 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
  * 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
    (7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
  * 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
  * 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
    Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
  否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。

验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
  恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
  确认 scoop.7z 无误后可以自行删除。
2026-09-22 08:18:16 +08:00

364 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 | 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