3152 行、66 个函数的单文件模块拆成: BakNRet\BakNRet.psd1 模块清单:FunctionsToExport 是显式白名单(62 个名字) BakNRet\BakNRet.psm1 加载器:点源顺序的唯一一处声明 BakNRet\Public\*.ps1 62 个对外函数,一函数一文件,文件名 = 函数名 BakNRet\Private\*.ps1 4 个内部函数 + State.ps1(模块级状态集中一处) 为什么是一函数一文件:这是社区里脚本模块的主流形态(调研实测:winutil 79 个、 Terminal-Icons 24 个、ModuleBuilder 23 个,全部如此)。收益是改动落在小文件里、diff 按职责 可读、模块级状态有唯一去处。 为什么这不算"打散":模块内 dot-source 的文件共享同一个模块作用域(实测确认),所以 "按顺序点源 67 个文件"与"点源一个大文件"在语义上等价;顺序只在加载器里出现一次, tools\Build-BakNRetModule.ps1 从那里读出顺序就能拼回单文件 —— 本次产物 dist\BakNRet.psm1 3240 行、两个版本都解析零错。 新增一条断言把这条承诺钉住:加载器点源的文件集合必须与磁盘一致、导出名单必须与 Public\ 一一对应。漏一个文件或漏一个名字就是静默少一个函数 —— 而那种错在运行时只表现为 "找不到命令"。 引用更新:11 个文件里的 Common.psm1 改成 BakNRet\BakNRet.psd1(走清单导入, FunctionsToExport 才真的说了算);Common.psm1 直接删除,不留转发垫片。 验收:test.ps1 9/9 全绿(7 与 5.1),98 个文件两版解析零错,276 个断言原样通过 —— 这次搬家没有改变任何可观察行为。
561 lines
24 KiB
PowerShell
561 lines
24 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
真实归档的恢复演练:把**硬盘上真实的归档**恢复到临时目标,再和活的源目录逐字节对拍。
|
||
|
||
.DESCRIPTION
|
||
和 tests/Run-E2E.ps1 的分工:
|
||
* Run-E2E.ps1 用自己造的假数据,证明的是"整条链路能跑通";
|
||
* 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。
|
||
|
||
关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog),
|
||
把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
|
||
而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。
|
||
|
||
归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有):
|
||
* 重构后的新布局:包内顶层是 Slot 名(`<Slot>\<内容>`,文件 Slot 就是名为
|
||
`<Slot>` 的文件)——manifest 记录的 layouts 里有这个名字;
|
||
* 重构前的旧布局:包内顶层是源路径的末级名(`<末级名>\...`)——manifest 没有 layouts。
|
||
本脚本按 manifest 判断,把临时名录的 Slot 名设成归档里**真实存在的那一层名字**,
|
||
因此新旧布局都能被 Restore.ps1 正常解出来,而不是依赖"解不出来再回退"。
|
||
|
||
对拍规则(关键:先把"源变了"和"归档坏了"分开):
|
||
* 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过,
|
||
只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败;
|
||
* 归档里有、活源里没有的文件:如果它所在的活源目录(或最近的还在的祖辈)
|
||
的修改时间晚于归档时间 ⇒ 是备份之后从源里删掉的,只提示、不算失败;
|
||
否则 ⇒ 归档里混进了源里没有的东西,算失败;
|
||
* 活源里在备份之后新增的文件只提示;
|
||
* 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。
|
||
|
||
真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据;
|
||
上面对"源变了"的区分让这个演练在活的机器上也能天天跑。
|
||
|
||
.EXAMPLE
|
||
# 用真实归档(默认读 BackupConfig.psd1 里的 BackupDir)做演练
|
||
pwsh -File .\tests\Restore-Drill.ps1
|
||
|
||
.EXAMPLE
|
||
# 只演练指定条目(写 BackupList.txt 里那样的行:软件名或绝对路径),并保留临时目录
|
||
pwsh -File .\tests\Restore-Drill.ps1 -Entries 'OpenSSH','C:\Programs\MiFlash' -KeepWorkRoot
|
||
#>
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
# 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir
|
||
[string]$BackupDir,
|
||
|
||
# 要演练的条目,写法与 BackupList.txt 的一行相同(软件名或绝对路径)。
|
||
# 默认是一组"小、静态、无排除规则"的条目;不存在的源 / 归档会被干净地跳过。
|
||
[string[]]$Entries = @(
|
||
'OpenSSH', 'Legendary', 'OpenCode', 'PowerShell', 'WindowsPowerShell',
|
||
'WindowsTerminal', 'TranslucentTB', 'Kazumi', 'PiliPlus',
|
||
'C:\Programs\MiFlash', 'C:\Programs\MiFlash_Unlock',
|
||
'D:\UserData\Documents\Aria'
|
||
),
|
||
|
||
[string]$ConfigPath,
|
||
|
||
[string]$WorkRoot,
|
||
|
||
# 活源在备份之后变过的文件只告警、不算失败
|
||
[switch]$AllowChanged,
|
||
|
||
[switch]$KeepWorkRoot
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||
|
||
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
|
||
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
|
||
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
|
||
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
|
||
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' }
|
||
|
||
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
|
||
|
||
Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -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
|
||
|
||
$manifest = Read-BaknretManifest -Path (Join-Path $BackupDir 'manifest.json')
|
||
$archiveFiles = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') })
|
||
|
||
# Restore.ps1 恢复成功后会**写回 manifest.json**(记 lastRestoreAt)。真实 Backups\ 只能读,
|
||
# 所以给子进程一个临时 BackupDir:里面放一份 manifest 副本 + 指向真实归档的符号链接
|
||
# (建不出符号链接就退化成复制)。这样归档还是那批真货,但写只会写进临时目录。
|
||
$scratchBackupRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-drill-backups-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
New-Item -ItemType Directory -Path $scratchBackupRoot -Force | Out-Null
|
||
$realManifestPath = Join-Path $BackupDir 'manifest.json'
|
||
if (Test-Path -LiteralPath $realManifestPath) {
|
||
Copy-Item -LiteralPath $realManifestPath -Destination (Join-Path $scratchBackupRoot 'manifest.json') -Force
|
||
}
|
||
|
||
Write-Host ''
|
||
Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan
|
||
Write-Host " 归档目录:$BackupDir(只读;恢复写盘只写临时目录)"
|
||
Write-Host " 软件名录:$catalogPath"
|
||
Write-Host " 工作目录:$WorkRoot"
|
||
Write-Host ''
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具
|
||
# ---------------------------------------------------------------------------
|
||
|
||
function Test-RemovedFromLiveAfterBackup {
|
||
<#
|
||
.SYNOPSIS
|
||
归档里有、活源里没有的文件,是不是"备份之后从源里删掉了"。
|
||
|
||
.DESCRIPTION
|
||
从活源根往下走,停在第一个不存在的层级,看最近的那个还在的祖辈的修改时间:
|
||
晚于归档时间 ⇒ 这个文件是在备份之后被删的(源变了,不是归档坏了);
|
||
不晚于归档时间 ⇒ 它本该还在,归档里却有别人没有的东西,算失败。
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$LiveRoot,
|
||
[Parameter(Mandatory = $true)][string]$Relative,
|
||
[Parameter(Mandatory = $true)][datetime]$ArchiveTime
|
||
)
|
||
|
||
$probe = $LiveRoot
|
||
foreach ($segment in @($Relative -split '[\\/]' | Where-Object { $_ })) {
|
||
$next = Join-Path $probe $segment
|
||
if (-not (Test-Path -LiteralPath $next)) { break }
|
||
$probe = $next
|
||
}
|
||
|
||
$item = Get-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
|
||
if (-not $item) { return $false }
|
||
return ($item.LastWriteTime -gt $ArchiveTime)
|
||
}
|
||
|
||
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 = @()
|
||
Removed = @()
|
||
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)) {
|
||
if (Test-RemovedFromLiveAfterBackup -LiveRoot $liveRoot -Relative $relative -ArchiveTime $ArchiveTime) {
|
||
$report.Removed += $relative
|
||
} else {
|
||
$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
|
||
}
|
||
|
||
function Get-ArchiveRelativeName {
|
||
<#
|
||
.SYNOPSIS
|
||
决定一个归档项在**这个归档里**实际叫什么名字。
|
||
|
||
.DESCRIPTION
|
||
manifest 里有 layouts(重构后写的归档)时,项名就是 Slot 名;
|
||
没有 layouts(重构前的归档)时,包内那一层是源路径的末级名。
|
||
名字对不上就解不出东西,所以这里必须按归档的真实布局来选。
|
||
#>
|
||
param($Item, $LayoutKinds)
|
||
|
||
if ($LayoutKinds.Count -gt 0) {
|
||
if ($LayoutKinds.ContainsKey([string]$Item.ArchivePath)) { return [string]$Item.ArchivePath }
|
||
return $null
|
||
}
|
||
|
||
return (Split-Path -Path ([string]$Item.RealPath) -Leaf)
|
||
}
|
||
|
||
function Invoke-ScratchRestore {
|
||
<#
|
||
.SYNOPSIS
|
||
用子进程跑 Restore.ps1,返回退出码与它自己的日志文件。
|
||
|
||
.DESCRIPTION
|
||
绝不能 `$lines = & pwsh @args 2>&1`:那会给子进程建管道,本机沙箱直接拒绝
|
||
(Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied)。
|
||
Invoke-ExternalCommand 继承 stdio、不建管道,退出码可靠,所以这里用它启动子进程;
|
||
子进程的输出不用管道拿,而是读它自己写下的 restore-*.log。
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$ScratchList,
|
||
[Parameter(Mandatory = $true)][string]$ScratchConfig,
|
||
[Parameter(Mandatory = $true)][string]$ScratchLogDir,
|
||
[Parameter(Mandatory = $true)][string]$ScratchBackupDir
|
||
)
|
||
|
||
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source)
|
||
if (-not $pwshExe) { $pwshExe = 'pwsh' }
|
||
|
||
New-Item -ItemType Directory -Path $ScratchLogDir -Force | Out-Null
|
||
$before = @(Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue |
|
||
Select-Object -ExpandProperty FullName)
|
||
|
||
$code = Invoke-ExternalCommand -FilePath $pwshExe -ArgumentList @(
|
||
'-NoProfile', '-NonInteractive', '-File', $restoreScript,
|
||
'-BackupListPath', $ScratchList,
|
||
'-ConfigPath', $ScratchConfig,
|
||
'-BackupDir', $ScratchBackupDir,
|
||
'-Force'
|
||
)
|
||
|
||
$log = Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue |
|
||
Where-Object { $before -notcontains $_.FullName } |
|
||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||
|
||
return [pscustomobject]@{ Code = $code; Log = $log }
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 演练
|
||
# ---------------------------------------------------------------------------
|
||
|
||
$rows = @()
|
||
$failures = @()
|
||
$checked = 0
|
||
$entryIndex = 0
|
||
|
||
foreach ($name in $Entries) {
|
||
$entryIndex++
|
||
$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
|
||
}
|
||
|
||
$items = @($resolved.Items)
|
||
if ($items.Count -eq 0) {
|
||
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出归档项' }
|
||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $reason }
|
||
continue
|
||
}
|
||
|
||
# 找到归档:manifest 记录优先,其次按归档基础名 / 源路径末级名精确匹配文件。
|
||
# Backups\ 里既有按软件名命名的归档,也有按路径算法命名的旧归档。
|
||
$legacyLeaves = @($items | ForEach-Object { Split-Path -Path ([string]$_.RealPath) -Leaf } | Where-Object { $_ })
|
||
|
||
$archiveFile = $null
|
||
$record = $null
|
||
if ($manifest.items.Contains($resolved.BaseName)) { $record = $manifest.items[$resolved.BaseName] }
|
||
if ($record -and ($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) {
|
||
$candidate = Join-Path $BackupDir ([string]$record.archive)
|
||
if (Test-Path -LiteralPath $candidate) { $archiveFile = Get-Item -LiteralPath $candidate }
|
||
}
|
||
|
||
if (-not $archiveFile) {
|
||
$matched = @()
|
||
foreach ($file in $archiveFiles) {
|
||
if ($file.BaseName -ieq $resolved.BaseName) { $matched += $file; continue }
|
||
foreach ($leaf in $legacyLeaves) {
|
||
if ($file.BaseName -ieq $leaf) { $matched += $file; break }
|
||
}
|
||
}
|
||
if ($matched.Count -gt 1) {
|
||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多个归档都可能是它:$(($matched | ForEach-Object { $_.Name }) -join '、')" }
|
||
continue
|
||
}
|
||
if ($matched.Count -eq 1) { $archiveFile = $matched[0] }
|
||
}
|
||
|
||
if (-not $archiveFile) {
|
||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在(基础名 $($resolved.BaseName))" }
|
||
continue
|
||
}
|
||
|
||
$archiveTime = $archiveFile.LastWriteTime
|
||
|
||
# 让子进程的 BackupDir 里也"有"这个归档:优先符号链接(零拷贝),不行才复制
|
||
$scratchArchive = Join-Path $scratchBackupRoot $archiveFile.Name
|
||
if (-not (Test-Path -LiteralPath $scratchArchive)) {
|
||
try {
|
||
New-Item -ItemType SymbolicLink -Path $scratchArchive -Target $archiveFile.FullName -ErrorAction Stop | Out-Null
|
||
} catch {
|
||
Copy-Item -LiteralPath $archiveFile.FullName -Destination $scratchArchive -Force
|
||
}
|
||
}
|
||
|
||
# 归档里那一层的真名:manifest.layouts 决定(新布局 = Slot 名,旧布局 = 末级名)
|
||
if (-not $record -and $manifest.items.Contains($archiveFile.BaseName)) { $record = $manifest.items[$archiveFile.BaseName] }
|
||
$layoutKinds = @{}
|
||
if ($record -and ($record.PSObject.Properties.Name -contains 'layouts') -and $record.layouts) {
|
||
foreach ($layout in @($record.layouts)) {
|
||
if (-not $layout) { continue }
|
||
$layoutName = [string]$layout.name
|
||
if (-not [string]::IsNullOrWhiteSpace($layoutName)) { $layoutKinds[$layoutName] = [string]$layout.kind }
|
||
}
|
||
}
|
||
|
||
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') ("e$entryIndex")
|
||
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null
|
||
|
||
$pairs = @()
|
||
$slotLines = @()
|
||
$skipReason = $null
|
||
|
||
for ($index = 0; $index -lt $items.Count; $index++) {
|
||
$item = $items[$index]
|
||
$livePath = [string]$item.RealPath
|
||
if ([string]::IsNullOrWhiteSpace($livePath)) { continue }
|
||
|
||
$liveItem = Get-Item -LiteralPath $livePath -Force -ErrorAction SilentlyContinue
|
||
if (-not $liveItem) { continue } # 源没了,跳过(归档里也不该有它)
|
||
|
||
$archiveName = Get-ArchiveRelativeName -Item $item -LayoutKinds $layoutKinds
|
||
if ([string]::IsNullOrWhiteSpace($archiveName)) {
|
||
$skipReason = "manifest.layouts 里没有归档项 '$($item.ArchivePath)'(名录改过?)"
|
||
break
|
||
}
|
||
if (@($pairs | Where-Object { $_.Name -ieq $archiveName }).Count -gt 0) {
|
||
$skipReason = "多个源都映射到归档内的同一个名字 '$archiveName',无法判定谁是谁(旧归档常见)"
|
||
break
|
||
}
|
||
|
||
$target = Join-Path $entryRoot ([string]$index)
|
||
if ($liveItem.PSIsContainer) {
|
||
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||
} else {
|
||
[System.IO.File]::WriteAllText($target, '')
|
||
}
|
||
|
||
$pairs += [pscustomobject]@{ Name = $archiveName; Restored = $target; Live = $livePath }
|
||
$slotLines += " '$archiveName' = @{ Path = '$target' }"
|
||
}
|
||
|
||
if ($skipReason) {
|
||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $skipReason }
|
||
continue
|
||
}
|
||
|
||
if ($pairs.Count -eq 0) {
|
||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' }
|
||
continue
|
||
}
|
||
|
||
# 临时名录:键 = 归档基础名(这样 Restore 能通过 manifest / 文件名找到归档),
|
||
# 每个 Slot 的 Path 指向一个临时目标 —— Restore 就解到这里,碰不到真实目录。
|
||
$catalogKey = $archiveFile.BaseName
|
||
$scratchCatalog = Join-Path $WorkRoot ("catalog-e$entryIndex.psd1")
|
||
$scratchList = Join-Path $WorkRoot ("list-e$entryIndex.txt")
|
||
$scratchConfig = Join-Path $WorkRoot ("config-e$entryIndex.psd1")
|
||
$scratchLogDir = Join-Path $WorkRoot ("logs\e$entryIndex")
|
||
|
||
[System.IO.File]::WriteAllText($scratchCatalog,
|
||
"@{`n '$catalogKey' = @{`n$($slotLines -join "`n")`n }`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||
[System.IO.File]::WriteAllText($scratchList, "$catalogKey`n", [System.Text.UTF8Encoding]::new($true))
|
||
[System.IO.File]::WriteAllText($scratchConfig, @"
|
||
@{
|
||
BackupDir = '$scratchBackupRoot'
|
||
LogDir = '$scratchLogDir'
|
||
SoftwareCatalog = '$scratchCatalog'
|
||
CatalogMaxDepth = $($config.CatalogMaxDepth)
|
||
VerifyArchive = `$true
|
||
}
|
||
"@, [System.Text.UTF8Encoding]::new($false))
|
||
|
||
Write-Host ("-- 演练 {0}(归档 {1},{2} 个源)" -f $name, $archiveFile.Name, $pairs.Count) -ForegroundColor Gray
|
||
|
||
$restore = Invoke-ScratchRestore -ScratchList $scratchList -ScratchConfig $scratchConfig -ScratchLogDir $scratchLogDir -ScratchBackupDir $scratchBackupRoot
|
||
|
||
if ($restore.Code -ne 0) {
|
||
if ($restore.Log) {
|
||
foreach ($line in @(Get-Content -Encoding UTF8 -LiteralPath $restore.Log.FullName -ErrorAction SilentlyContinue | Select-Object -Last 12)) {
|
||
Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray
|
||
}
|
||
}
|
||
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $($restore.Code)" }
|
||
$failures += "$name :Restore.ps1 退出码 $($restore.Code)"
|
||
continue
|
||
}
|
||
|
||
$checked++
|
||
$matched = 0
|
||
$restoredCount = 0
|
||
$extra = @()
|
||
$stale = @()
|
||
$removed = @()
|
||
$changed = @()
|
||
$notArchived = @()
|
||
|
||
foreach ($pair in $pairs) {
|
||
$one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime
|
||
$matched += $one.Matched
|
||
$restoredCount += $one.Restored
|
||
$extra += $one.Extra
|
||
$stale += $one.Stale
|
||
$removed += $one.Removed
|
||
$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 ($removed.Count -gt 0) {
|
||
# 归档里有、活源里没了,且源目录在归档之后动过:也是"源变了",只提示
|
||
$detail += ";备份后从源里删掉 $($removed.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 }
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 报告
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# 临时 BackupDir 用完即删:删符号链接只会删链接本身,真实的归档不受影响
|
||
Remove-Item -LiteralPath $scratchBackupRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||
|
||
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
|