chore: 记录改造前基线
改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。 包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。 .gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
This commit is contained in:
1 parent
7173e8ae10
commit
2937eb6652
32 files changed
+8775
-1691
No files matched your search
+261
-71
@@ -8,14 +8,23 @@
|
||||
* 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。
|
||||
|
||||
关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog),
|
||||
把软件名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
|
||||
把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
|
||||
而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。
|
||||
|
||||
归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有):
|
||||
* 重构后的新布局:包内顶层是 Slot 名(`<Slot>\<内容>`,文件 Slot 就是名为
|
||||
`<Slot>` 的文件)——manifest 记录的 layouts 里有这个名字;
|
||||
* 重构前的旧布局:包内顶层是源路径的末级名(`<末级名>\...`)——manifest 没有 layouts。
|
||||
本脚本按 manifest 判断,把临时名录的 Slot 名设成归档里**真实存在的那一层名字**,
|
||||
因此新旧布局都能被 Restore.ps1 正常解出来,而不是依赖"解不出来再回退"。
|
||||
|
||||
对拍规则(关键:先把"源变了"和"归档坏了"分开):
|
||||
* 恢复树里每个文件都必须在活源里存在 —— 否则失败(说明归档里混进了别的东西);
|
||||
* 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过,
|
||||
只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败;
|
||||
* 活源里在备份之后新增 / 删掉的文件只提示;
|
||||
* 归档里有、活源里没有的文件:如果它所在的活源目录(或最近的还在的祖辈)
|
||||
的修改时间晚于归档时间 ⇒ 是备份之后从源里删掉的,只提示、不算失败;
|
||||
否则 ⇒ 归档里混进了源里没有的东西,算失败;
|
||||
* 活源里在备份之后新增的文件只提示;
|
||||
* 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。
|
||||
|
||||
真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据;
|
||||
@@ -26,8 +35,8 @@
|
||||
pwsh -File .\tests\Restore-Drill.ps1
|
||||
|
||||
.EXAMPLE
|
||||
# 只演练指定条目,并保留下临时工作目录
|
||||
pwsh -File .\tests\Restore-Drill.ps1 -Entries '.ssh','legendary' -KeepWorkRoot
|
||||
# 只演练指定条目(写 BackupList.txt 里那样的行:软件名或绝对路径),并保留临时目录
|
||||
pwsh -File .\tests\Restore-Drill.ps1 -Entries 'OpenSSH','C:\Programs\MiFlash' -KeepWorkRoot
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -35,11 +44,13 @@ param(
|
||||
# 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir
|
||||
[string]$BackupDir,
|
||||
|
||||
# 要演练的条目(软件名)。默认是一组"小、静态、无排除规则"的条目
|
||||
# 要演练的条目,写法与 BackupList.txt 的一行相同(软件名或绝对路径)。
|
||||
# 默认是一组"小、静态、无排除规则"的条目;不存在的源 / 归档会被干净地跳过。
|
||||
[string[]]$Entries = @(
|
||||
'.ssh', 'legendary', 'scoop-config', 'opencode',
|
||||
'PowerShell', 'WindowsPowerShell', 'MiFlash', 'MiFlash_Unlock',
|
||||
'Startup', 'WindowsTerminal', 'Aria'
|
||||
'OpenSSH', 'Legendary', 'OpenCode', 'PowerShell', 'WindowsPowerShell',
|
||||
'WindowsTerminal', 'TranslucentTB', 'Kazumi', 'PiliPlus',
|
||||
'C:\Programs\MiFlash', 'C:\Programs\MiFlash_Unlock',
|
||||
'D:\UserData\Documents\Aria'
|
||||
),
|
||||
|
||||
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'),
|
||||
@@ -82,9 +93,23 @@ if (-not $WorkRoot) {
|
||||
}
|
||||
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 " 归档目录:$BackupDir(只读;恢复写盘只写临时目录)"
|
||||
Write-Host " 软件名录:$catalogPath"
|
||||
Write-Host " 工作目录:$WorkRoot"
|
||||
Write-Host ''
|
||||
@@ -93,6 +118,34 @@ 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
|
||||
@@ -115,6 +168,7 @@ function Compare-RestoredTree {
|
||||
Restored = 0
|
||||
Matched = 0
|
||||
Stale = @()
|
||||
Removed = @()
|
||||
Changed = @()
|
||||
Extra = @()
|
||||
Missing = @()
|
||||
@@ -151,7 +205,11 @@ function Compare-RestoredTree {
|
||||
$liveFile = Join-Path $liveRoot $relative
|
||||
|
||||
if (-not (Test-Path -LiteralPath $liveFile)) {
|
||||
$report.Extra += $relative
|
||||
if (Test-RemovedFromLiveAfterBackup -LiveRoot $liveRoot -Relative $relative -ArchiveTime $ArchiveTime) {
|
||||
$report.Removed += $relative
|
||||
} else {
|
||||
$report.Extra += $relative
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -179,6 +237,66 @@ function Compare-RestoredTree {
|
||||
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 }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 演练
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -186,8 +304,10 @@ function Compare-RestoredTree {
|
||||
$rows = @()
|
||||
$failures = @()
|
||||
$checked = 0
|
||||
$entryIndex = 0
|
||||
|
||||
foreach ($name in $Entries) {
|
||||
$entryIndex++
|
||||
$entry = ConvertFrom-BackupListLine -Line $name
|
||||
if (-not $entry) { continue }
|
||||
|
||||
@@ -199,84 +319,147 @@ foreach ($name in $Entries) {
|
||||
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 = '名录解析不出源路径' }
|
||||
$items = @($resolved.Items)
|
||||
if ($items.Count -eq 0) {
|
||||
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出归档项' }
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $reason }
|
||||
continue
|
||||
}
|
||||
|
||||
if (@($sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }).Count -eq 0) {
|
||||
# 找到归档: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
|
||||
}
|
||||
|
||||
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name
|
||||
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null
|
||||
# 临时名录:键 = 归档基础名(这样 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")
|
||||
|
||||
# 每个源各自映射到一个临时目标:临时名录保持**同样的个数与顺序**,
|
||||
# 于是 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))
|
||||
"@{`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($false))
|
||||
[System.IO.File]::WriteAllText($scratchConfig, @"
|
||||
@{
|
||||
BackupDir = '$BackupDir'
|
||||
LogDir = '$(Join-Path $WorkRoot 'logs')'
|
||||
BackupDir = '$scratchBackupRoot'
|
||||
LogDir = '$scratchLogDir'
|
||||
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
|
||||
Write-Host ("-- 演练 {0}(归档 {1},{2} 个源)" -f $name, $archiveFile.Name, $pairs.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
|
||||
}
|
||||
$restore = Invoke-ScratchRestore -ScratchList $scratchList -ScratchConfig $scratchConfig -ScratchLogDir $scratchLogDir -ScratchBackupDir $scratchBackupRoot
|
||||
|
||||
if ($restoreExit -ne 0) {
|
||||
foreach ($line in @($restoreOutput | Select-Object -Last 12)) {
|
||||
Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray
|
||||
if ($restore.Code -ne 0) {
|
||||
if ($restore.Log) {
|
||||
foreach ($line in @(Get-Content -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 退出码 $restoreExit" }
|
||||
$failures += "$name :Restore.ps1 退出码 $restoreExit"
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $($restore.Code)" }
|
||||
$failures += "$name :Restore.ps1 退出码 $($restore.Code)"
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -285,17 +468,17 @@ foreach ($name in $Entries) {
|
||||
$restoredCount = 0
|
||||
$extra = @()
|
||||
$stale = @()
|
||||
$removed = @()
|
||||
$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
|
||||
$removed += $one.Removed
|
||||
$changed += $one.Changed
|
||||
$notArchived += $one.Missing
|
||||
}
|
||||
@@ -312,6 +495,10 @@ foreach ($name in $Entries) {
|
||||
# 活源在归档之后被改过:源变了,不是归档坏了,只提示
|
||||
$detail += ";源在备份后变过 $($stale.Count) 个(不算失败)"
|
||||
}
|
||||
if ($removed.Count -gt 0) {
|
||||
# 归档里有、活源里没了,且源目录在归档之后动过:也是"源变了",只提示
|
||||
$detail += ";备份后从源里删掉 $($removed.Count) 个(不算失败)"
|
||||
}
|
||||
if ($changed.Count -gt 0) {
|
||||
if ($AllowChanged) {
|
||||
$detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)"
|
||||
@@ -338,6 +525,9 @@ foreach ($name in $Entries) {
|
||||
# 报告
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 临时 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
|
||||
|
||||
Reference in new issue
Block a user