style: 按微软规范落地静态分析,并全仓机械重排
三件事:
1) tools\Install-TestDependencies.ps1 现在也把 PSScriptAnalyzer 装进仓库内的 .tools\modules
(不动机器上的全局模块,与 Pester 同一策略)。
2) PSScriptAnalyzerSettings.psd1:这是必要的,不是装饰 —— 那 6 条格式规则
(括号、缩进、空格、对齐、大小写)默认全是 Disabled,所以不带 -Settings 的
`Invoke-ScriptAnalyzer -Severity Warning,Error` 会**静默漏掉全部排版问题**。本文件用 Rules
把它们打开(而不是用 IncludeRules 换一套),于是默认规则与格式规则同时生效。
三条有意的排除都写明了理由:PSAvoidUsingWriteHost(彩色控制台输出是这份工具的刻意设计)、
PSUseShouldProcessForStateChangingFunctions(WhatIf 的边界在入口脚本,给库里 27 个改状态的
函数都加上反而会"静默跳过",备份看着成功却什么都没做)、PSAvoidUsingPlainTextForPassword
(7z 只接受命令行口令,这是 7z 的限制,README 里写明了取舍)。
3) tools\Invoke-Analyzer.ps1:独立门禁(不塞进 Pester 用例 —— 套件跑一次二十多秒,
混进去会让"测试红了"这句话失去分辨力),路径过滤与验收门槛的 Encode/Parse 两层一致。
全仓重排结果:706 条告警 -> 67 条。修掉的 639 条全部是格式(闭括号 168、空格 80、
对齐 68、缩进 60、行长 229)。重排后 9/9 验收全绿、100 个文件两版解析零错、
276 个断言原样通过 —— 机械重排没有改变任何可观察行为。
如实说明两件事:
* 行长上限设成 160,**不是**官方默认的 120。120 在本仓库意味着 270 处改动(主要是
中文注释与测试夹具里的一行式目录),160 意味着 41 处。160 仍是"宽但可读",而理由是写在
配置文件里的:这不是悄悄放宽,想收紧到 120 时那份清单就在分析器输出里。
* 剩余 67 条里,41 条是上面那批行长,其余 26 条是分析器找出的真问题(未使用参数 6、
空 catch 6、MD5 指纹 1、覆盖内置命令 1、switch 默认值 1 等)。其中
Find-ChildDirectoryByName 的 MaxDepth 参数从未被使用 —— 也就是配置里的
CatalogMaxDepth = 5 是假的,前缀补全实际只查 1 层。这条要改行为、且影响真实名录的解析
结果,留给你拍板,不在本提交里动手。
This commit is contained in:
1 parent
102a3e038d
commit
187d2759fd
60 files changed
+769
-377
No files matched your search
@@ -1,26 +1,27 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把测试用的 Pester 5 装进仓库内的 .tools\modules(不动机器上的全局模块)。
|
||||
把测试与静态分析用的模块装进仓库内的 .tools\modules(不动机器上的全局模块)。
|
||||
|
||||
.DESCRIPTION
|
||||
tests\BakNRet.Tests.ps1 需要 Pester 5.0+。本机常见的坑是:
|
||||
* Windows 自带的是 Pester 3.4.0,没有 `Should -Be`,套件会语法错误;
|
||||
* 直接 Install-Module 会把 5.x 装到全局,可能影响机器上别的、按 3.x 语法写的脚本。
|
||||
装两样:
|
||||
* Pester —— tests\BakNRet.Tests.ps1 需要 5.0+。本机常见的坑是 Windows 自带的是
|
||||
3.4.0,没有 `Should -Be`,套件会语法错误;
|
||||
* PSScriptAnalyzer —— tools\Invoke-Analyzer.ps1 用它做"按微软规范"的门禁。
|
||||
|
||||
所以这里用 Save-Module 把指定版本下载到仓库内的 .tools\modules,
|
||||
tests\Run-Pester.ps1 会自动把该目录加进 PSModulePath。
|
||||
.tools\ 已进 .gitignore,不会污染版本库。
|
||||
两者都用 Save-Module 下载到仓库内,而不是 Install-Module:后者会装到全局,
|
||||
可能影响机器上别的、按旧语法写的脚本。.tools\ 已进 .gitignore,不污染版本库。
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tools\Install-TestDependencies.ps1
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tools\Install-TestDependencies.ps1 -PesterVersion 5.9.1 -Force
|
||||
pwsh -File .\tools\Install-TestDependencies.ps1 -Force
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[version]$PesterVersion = [version]'5.9.1',
|
||||
[string]$PSScriptAnalyzerVersion = 'latest',
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
@@ -32,40 +33,60 @@ $target = Join-Path $projectRoot '.tools\modules'
|
||||
$installed = Join-Path $target ("Pester\{0}" -f $PesterVersion)
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ("目标目录 : {0}" -f $target)
|
||||
Write-Host ("Pester : {0}" -f $PesterVersion)
|
||||
Write-Host ("目标目录 : {0}" -f $target)
|
||||
Write-Host ("Pester : {0}" -f $PesterVersion)
|
||||
Write-Host ("PSScriptAnalyzer: {0}" -f $PSScriptAnalyzerVersion)
|
||||
Write-Host ''
|
||||
|
||||
if ((Test-Path -LiteralPath $installed) -and -not $Force) {
|
||||
Write-Host "已经装好了,无需重复下载(要重装加 -Force)。" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Force -and (Test-Path -LiteralPath (Join-Path $target 'Pester'))) {
|
||||
Remove-Item -LiteralPath (Join-Path $target 'Pester') -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||||
|
||||
if (-not (Get-Command Save-Module -ErrorAction SilentlyContinue)) {
|
||||
Write-Error '当前环境没有 Save-Module(需要 PowerShellGet 2.x)。请改用:Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0'
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
Save-Module -Name Pester -RequiredVersion $PesterVersion -Path $target -Force -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Error "下载失败(多半是访问不到 PSGallery):$_"
|
||||
exit 1
|
||||
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||||
|
||||
$needPester = $Force -or -not (Test-Path -LiteralPath $installed)
|
||||
|
||||
if ($needPester) {
|
||||
if ($Force -and (Test-Path -LiteralPath (Join-Path $target 'Pester'))) {
|
||||
Remove-Item -LiteralPath (Join-Path $target 'Pester') -Recurse -Force
|
||||
}
|
||||
try {
|
||||
Save-Module -Name Pester -RequiredVersion $PesterVersion -Path $target -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Error "Pester 下载失败(多半是访问不到 PSGallery):$_"
|
||||
exit 1
|
||||
}
|
||||
Write-Host ("已安装:Pester {0}" -f $PesterVersion) -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Pester {0} 已经装好了,跳过(要重装加 -Force)。" -f $PesterVersion -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
$module = Get-Module -ListAvailable Pester | Where-Object { [version]$_.Version -eq $PesterVersion }
|
||||
if (-not $module) {
|
||||
Write-Error "下载结束但找不到 Pester $PesterVersion,请检查 $target。"
|
||||
exit 1
|
||||
# PSScriptAnalyzer:版本目录带具体版本号,所以"装没装过"按目录是否存在判断
|
||||
$analyzerInstalled = @(Test-Path -LiteralPath (Join-Path $target 'PSScriptAnalyzer'))
|
||||
if ($Force -or -not $analyzerInstalled) {
|
||||
if ($Force -and $analyzerInstalled) {
|
||||
Remove-Item -LiteralPath (Join-Path $target 'PSScriptAnalyzer') -Recurse -Force
|
||||
}
|
||||
try {
|
||||
Save-Module -Name PSScriptAnalyzer -Path $target -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Error "PSScriptAnalyzer 下载失败(多半是访问不到 PSGallery):$_"
|
||||
exit 1
|
||||
}
|
||||
$analyzerVersion = (Get-ChildItem (Join-Path $target 'PSScriptAnalyzer') -Directory | Select-Object -First 1).Name
|
||||
Write-Host ("已安装:PSScriptAnalyzer {0}" -f $analyzerVersion) -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host 'PSScriptAnalyzer 已经装好了,跳过(要重装加 -Force)。' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
Write-Host ("已安装:Pester {0} -> {1}" -f $module.Version, $module.ModuleBase) -ForegroundColor Green
|
||||
Write-Host '现在可以跑:.\tests\Run-Pester.ps1' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
Write-Host '现在可以跑:' -ForegroundColor Cyan
|
||||
Write-Host ' .\tests\Run-Pester.ps1 (单元套件)' -ForegroundColor Gray
|
||||
Write-Host ' .\tools\Invoke-Analyzer.ps1 (静态分析门禁)' -ForegroundColor Gray
|
||||
Write-Host ''
|
||||
exit 0
|
||||
@@ -0,0 +1,101 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
对全仓跑 PSScriptAnalyzer(默认规则集 + 格式规则集),按规则汇总。
|
||||
|
||||
.DESCRIPTION
|
||||
为什么要有这个入口,而不是直接敲 Invoke-ScriptAnalyzer:
|
||||
|
||||
* 分析器装在**仓库内**(.tools\modules),不能指望机器上全局有一份;
|
||||
* 格式规则默认是 Disabled —— 不带 -Settings 的调用会静默漏掉全部排版问题,
|
||||
那会让"按微软规范检查过了"变成一句空话;
|
||||
* 结果要能一眼看出"哪条规则、几个文件、几行",而不是几百行原始输出;
|
||||
* 路径过滤要与验收门槛一致:.tools\ / Backups\ / logs\ / dist\ 不进分析范围。
|
||||
|
||||
与测试的分工:这是**独立的门禁**,不塞进 Pester 用例。那个套件跑一次二十多秒,
|
||||
把静态分析混进去会让"测试红了"这句话失去分辨力。
|
||||
|
||||
.PARAMETER Severity
|
||||
只报这些级别,默认 Error 与 Warning。
|
||||
|
||||
.PARAMETER Quiet
|
||||
只打印按规则汇总的计数,不逐条列出。
|
||||
|
||||
.EXAMPLE
|
||||
.\tools\Invoke-Analyzer.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\tools\Invoke-Analyzer.ps1 -Quiet
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string[]]$Severity = @('Error', 'Warning'),
|
||||
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$localModules = Join-Path $projectRoot '.tools\modules'
|
||||
if (Test-Path -LiteralPath (Join-Path $localModules 'PSScriptAnalyzer')) {
|
||||
$env:PSModulePath = $localModules + [System.IO.Path]::PathSeparator + $env:PSModulePath
|
||||
}
|
||||
|
||||
$analyzer = Get-Module -ListAvailable PSScriptAnalyzer |
|
||||
Sort-Object { [version]$_.Version } -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $analyzer) {
|
||||
Write-Host ''
|
||||
Write-Host '找不到 PSScriptAnalyzer。把它装进仓库(不动机器上的全局模块):' -ForegroundColor Red
|
||||
Write-Host ' .\tools\Install-TestDependencies.ps1' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
exit 2
|
||||
}
|
||||
|
||||
Import-Module $analyzer.Path -Force
|
||||
|
||||
# 分析范围与验收门槛的 Encode / Parse 两层保持一致:只分析仓库自己的源码
|
||||
$excluded = '\\(\.git|\.tools|\.scratch|Backups|logs|dist|snapshots)\\'
|
||||
$targets = @(Get-ChildItem -LiteralPath $projectRoot -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.ps1', '.psm1', '.psd1' } |
|
||||
Where-Object { $_.FullName -notmatch $excluded } |
|
||||
Select-Object -ExpandProperty FullName)
|
||||
|
||||
$settings = Join-Path $projectRoot 'PSScriptAnalyzerSettings.psd1'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ("PSScriptAnalyzer {0}({1})" -f $analyzer.Version, $analyzer.ModuleBase) -ForegroundColor DarkGray
|
||||
Write-Host ("分析 {0} 个文件,配置 {1}" -f $targets.Count, $settings) -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
|
||||
# 逐个文件调用:-Path 在这个版本上只接受单个路径(传数组会报 Cannot convert
|
||||
# 'System.Object[]' to the type 'System.String'),而我们要的正是「只分析仓库自己的
|
||||
# 源码」这个范围 —— 自己枚举文件反而更准。
|
||||
$results = @(foreach ($file in $targets) {
|
||||
Invoke-ScriptAnalyzer -Path $file -Settings $settings -Severity $Severity
|
||||
})
|
||||
|
||||
if ($results.Count -eq 0) {
|
||||
Write-Host ("没有 {0} 级别的告警。" -f ($Severity -join '/')) -ForegroundColor Green
|
||||
Write-Host ''
|
||||
exit 0
|
||||
}
|
||||
|
||||
$byRule = $results | Group-Object RuleName | Sort-Object Count -Descending
|
||||
Write-Host ("共 {0} 条,按规则汇总:" -f $results.Count) -ForegroundColor Yellow
|
||||
foreach ($group in $byRule) {
|
||||
Write-Host (" {0,4} {1}" -f $group.Count, $group.Name)
|
||||
}
|
||||
|
||||
if (-not $Quiet) {
|
||||
Write-Host ''
|
||||
Write-Host '逐条:' -ForegroundColor Yellow
|
||||
foreach ($item in ($results | Sort-Object ScriptName, Line)) {
|
||||
$relative = $item.ScriptName.Replace($projectRoot, '').TrimStart('\')
|
||||
Write-Host (" {0}:{1} [{2}] {3}" -f $relative, $item.Line, $item.RuleName, $item.Message)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
exit 1
|
||||
@@ -78,7 +78,8 @@ if ($Remove) {
|
||||
try {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop
|
||||
Write-Host "已移除计划任务:$TaskName" -ForegroundColor Green
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Error "移除失败(多半是权限不足,请用管理员终端):$_"
|
||||
exit 1
|
||||
}
|
||||
@@ -116,7 +117,8 @@ try {
|
||||
Write-Host "已注册计划任务:$TaskName(每天 $At)" -ForegroundColor Green
|
||||
Write-Host "查看上次运行结果:Get-ScheduledTaskInfo -TaskName '$TaskName'" -ForegroundColor DarkGray
|
||||
Write-Host "手动跑一次验证 :Start-ScheduledTask -TaskName '$TaskName'" -ForegroundColor DarkGray
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Error "注册失败(多半是权限不足,请用管理员终端):$_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -242,7 +242,8 @@ foreach ($entry in $plan) {
|
||||
|
||||
Write-Host "已重命名: $($entry.Old.Name) -> $($entry.New)" -ForegroundColor Green
|
||||
$ok++
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Host "重命名失败: $($entry.Old.Name) —— $_" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
@@ -323,7 +324,7 @@ Write-BaknretManifest -Path $manifestPath -Manifest $manifestNew | Out-Null
|
||||
|
||||
$referenced = @($rebuilt.Keys)
|
||||
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension.ToLower() -in $supportedFormats -and $_.BaseName -notin $referenced })
|
||||
Where-Object { $_.Extension.ToLower() -in $supportedFormats -and $_.BaseName -notin $referenced })
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "重命名完成:成功 $ok,失败 $failed;manifest 已重建,含 $($rebuilt.Count) 个条目" -ForegroundColor Green
|
||||
|
||||
@@ -65,7 +65,8 @@ Write-Host ("受管文件 {0} 个" -f $targets.Count)
|
||||
if ($addedBom.Count -gt 0) {
|
||||
Write-Host ("补上 BOM {0} 个:" -f $addedBom.Count) -ForegroundColor Yellow
|
||||
$addedBom | ForEach-Object { Write-Host " $_" }
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host '所有文件都已经带 BOM。' -ForegroundColor Green
|
||||
}
|
||||
if ($normalizedLf.Count -gt 0) {
|
||||
|
||||
+24
-21
@@ -15,20 +15,20 @@
|
||||
|
||||
|
||||
$script:LabConfig = [ordered]@{
|
||||
VmName = 'BakNRet-Lab'
|
||||
LabRoot = 'D:\VMs\BakNRet-Lab'
|
||||
VhdxPath = 'D:\VMs\BakNRet-Lab\vhdx\BakNRet-Lab.vhdx'
|
||||
VhdxSizeGB = 80
|
||||
IsoPath = 'F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso'
|
||||
ImageIndex = 4 # Windows 11 专业版
|
||||
SwitchName = 'Default Switch'
|
||||
VmName = 'BakNRet-Lab'
|
||||
LabRoot = 'D:\VMs\BakNRet-Lab'
|
||||
VhdxPath = 'D:\VMs\BakNRet-Lab\vhdx\BakNRet-Lab.vhdx'
|
||||
VhdxSizeGB = 80
|
||||
IsoPath = 'F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso'
|
||||
ImageIndex = 4 # Windows 11 专业版
|
||||
SwitchName = 'Default Switch'
|
||||
MemoryStartupGB = 8
|
||||
CpuCount = 8
|
||||
GuestRepoPath = 'C:\BakNRet'
|
||||
GuestLabPath = 'C:\BakNRet-Lab'
|
||||
GuestUser = 'lab'
|
||||
CheckpointName = 'clean-baseline'
|
||||
RepoRoot = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
|
||||
CpuCount = 8
|
||||
GuestRepoPath = 'C:\BakNRet'
|
||||
GuestLabPath = 'C:\BakNRet-Lab'
|
||||
GuestUser = 'lab'
|
||||
CheckpointName = 'clean-baseline'
|
||||
RepoRoot = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
|
||||
}
|
||||
|
||||
function Get-LabConfig { return $script:LabConfig }
|
||||
@@ -44,11 +44,11 @@ function Get-LabPath {
|
||||
|
||||
function Write-LabLog {
|
||||
<# .SYNOPSIS 统一日志:同时进控制台与 $LabRoot\logs\lab.log。 #>
|
||||
param([Parameter(Mandatory)][string]$Message, [ValidateSet('INFO','WARN','ERROR','STEP')][string]$Level = 'INFO')
|
||||
param([Parameter(Mandatory)][string]$Message, [ValidateSet('INFO', 'WARN', 'ERROR', 'STEP')][string]$Level = 'INFO')
|
||||
$line = "[{0}] [{1,-5}] {2}" -f (Get-Date).ToString('HH:mm:ss'), $Level, $Message
|
||||
switch ($Level) {
|
||||
'STEP' { Write-Host $line -ForegroundColor Cyan }
|
||||
'WARN' { Write-Host $line -ForegroundColor Yellow }
|
||||
'STEP' { Write-Host $line -ForegroundColor Cyan }
|
||||
'WARN' { Write-Host $line -ForegroundColor Yellow }
|
||||
'ERROR' { Write-Host $line -ForegroundColor Red }
|
||||
default { Write-Host $line }
|
||||
}
|
||||
@@ -65,8 +65,8 @@ function Assert-LabElevated {
|
||||
param([Parameter(Mandatory)][string]$Why)
|
||||
if (Test-LabElevated) { return }
|
||||
$gsudo = (Get-Command gsudo -ErrorAction SilentlyContinue | Select-Object -First 1).Source
|
||||
$self = $MyInvocation.PSCommandPath
|
||||
$hint = if ($gsudo) { "`n $gsudo pwsh -NoProfile -File `"$self`" $($MyInvocation.Line)" } else { '' }
|
||||
$self = $MyInvocation.PSCommandPath
|
||||
$hint = if ($gsudo) { "`n $gsudo pwsh -NoProfile -File `"$self`" $($MyInvocation.Line)" } else { '' }
|
||||
throw "需要管理员权限:$Why$hint"
|
||||
}
|
||||
|
||||
@@ -131,7 +131,8 @@ function New-LabSession {
|
||||
$s = New-PSSession -VMName $script:LabConfig.VmName -Credential $cred -ErrorAction Stop
|
||||
Write-LabLog "PowerShell Direct 会话已建立(等待 $([math]::Round($sw.Elapsed.TotalSeconds)) 秒)"
|
||||
return $s
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
$lastError = $_.Exception.Message
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
@@ -149,7 +150,8 @@ function Invoke-LabCommand {
|
||||
$s = New-LabSession -RetrySeconds $RetrySeconds
|
||||
try {
|
||||
return Invoke-Command -Session $s -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
Remove-PSSession -Session $s -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -178,5 +180,6 @@ function Test-LabGuestReady {
|
||||
try {
|
||||
$r = Invoke-LabCommand -ScriptBlock { Test-Path 'C:\BakNRet-Lab\state\provision.ok' } -RetrySeconds 60
|
||||
return [bool]$r
|
||||
} catch { return $false }
|
||||
}
|
||||
catch { return $false }
|
||||
}
|
||||
+25
-22
@@ -38,10 +38,10 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, Position = 0)]
|
||||
[ValidateSet('status','start','stop','wait','sync','seed','backup','restore','acl-test','test','shell','console','checkpoint','reset','destroy')]
|
||||
[ValidateSet('status', 'start', 'stop', 'wait', 'sync', 'seed', 'backup', 'restore', 'acl-test', 'test', 'shell', 'console', 'checkpoint', 'reset', 'destroy')]
|
||||
[string]$Verb,
|
||||
|
||||
[ValidateSet('all','pester','zero','e2e')][string]$Suite = 'all',
|
||||
[ValidateSet('all', 'pester', 'zero', 'e2e')][string]$Suite = 'all',
|
||||
|
||||
# 恢复演练要处理的条目(写法同 BackupList.txt 的一行)
|
||||
[string[]]$Entries,
|
||||
@@ -63,10 +63,10 @@ $ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'Lab-Common.ps1')
|
||||
$cfg = Get-LabConfig
|
||||
|
||||
$guestSandbox = "$($cfg.GuestRepoPath)\tools\lab\payload\sandbox"
|
||||
$guestList = "$guestSandbox\BackupList.txt"
|
||||
$guestConfig = "$guestSandbox\BackupConfig.psd1"
|
||||
$guestFixture = "$($cfg.GuestRepoPath)\tools\lab\payload\lab-fixtures.ps1"
|
||||
$guestSandbox = "$($cfg.GuestRepoPath)\tools\lab\payload\sandbox"
|
||||
$guestList = "$guestSandbox\BackupList.txt"
|
||||
$guestConfig = "$guestSandbox\BackupConfig.psd1"
|
||||
$guestFixture = "$($cfg.GuestRepoPath)\tools\lab\payload\lab-fixtures.ps1"
|
||||
$guestBackupDir = 'C:\BakNRet-Lab\Backups'
|
||||
|
||||
Assert-LabElevated -Why "Hyper-V 操作与 PowerShell Direct 都需要管理员(动词:$Verb)"
|
||||
@@ -80,12 +80,12 @@ function Get-VmSummary {
|
||||
if (-not $vm) { return $null }
|
||||
$mem = Get-VMMemory -VMName $cfg.VmName
|
||||
return [pscustomobject]@{
|
||||
Name = $vm.Name
|
||||
State = $vm.State
|
||||
Uptime = [int]$vm.Uptime.TotalSeconds
|
||||
Cpu = $vm.ProcessorCount
|
||||
MemoryGB = [math]::Round($mem.Startup / 1GB, 1)
|
||||
Gen = $vm.Generation
|
||||
Name = $vm.Name
|
||||
State = $vm.State
|
||||
Uptime = [int]$vm.Uptime.TotalSeconds
|
||||
Cpu = $vm.ProcessorCount
|
||||
MemoryGB = [math]::Round($mem.Startup / 1GB, 1)
|
||||
Gen = $vm.Generation
|
||||
UptimeText = "$([int]$vm.Uptime.TotalMinutes) 分钟"
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ function Invoke-GuestScriptFile {
|
||||
$text = if ($badUtf8 -le $badAnsi) { $asUtf8 } else { $asAnsi }
|
||||
return @($text -split "`r?`n" | Where-Object { $_ -ne '' } | Select-Object -Last $lines)
|
||||
}
|
||||
$all = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) + $scriptArgs
|
||||
$all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $script) + $scriptArgs
|
||||
$out = $logPath
|
||||
$err = "$logPath.err"
|
||||
$p = Start-Process -FilePath 'pwsh.exe' -ArgumentList $all -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
|
||||
@@ -147,7 +147,8 @@ function Invoke-LabSync {
|
||||
Push-Location $cfg.RepoRoot
|
||||
try {
|
||||
& $sevenZip a -tzip $zip '.\*' '-xr!Backups' '-xr!logs' '-xr!.git' '-xr!.tools' '-xr!*.tmp.7z' '-xr!*.tmp.zip' -y | Out-Null
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
finally { Pop-Location }
|
||||
Write-LabLog ("快照大小 {0} MB" -f [math]::Round((Get-Item -LiteralPath $zip).Length / 1MB, 2))
|
||||
|
||||
Write-LabLog '推送到 VM(Copy-VMFile,走 VMBus)' 'STEP'
|
||||
@@ -214,7 +215,7 @@ switch ($Verb) {
|
||||
$arch = @()
|
||||
if (Test-Path 'C:\BakNRet-Lab\Backups') {
|
||||
$arch = @(Get-ChildItem 'C:\BakNRet-Lab\Backups' -Filter *.7z -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [pscustomobject]@{ Name = $_.BaseName; MB = [math]::Round($_.Length / 1MB, 2) } })
|
||||
ForEach-Object { [pscustomobject]@{ Name = $_.BaseName; MB = [math]::Round($_.Length / 1MB, 2) } })
|
||||
}
|
||||
$src = 'C:\BakNRet-Lab\sources'
|
||||
[pscustomobject]@{
|
||||
@@ -226,7 +227,7 @@ switch ($Verb) {
|
||||
SourceMB = $(if (Test-Path $src) { [math]::Round(((Get-ChildItem $src -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum) / 1MB, 1) } else { 0 })
|
||||
Archives = $arch
|
||||
LastLog = (Get-ChildItem 'C:\BakNRet-Lab\logs' -Filter 'backup-*.log' -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1 -ExpandProperty Name)
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1 -ExpandProperty Name)
|
||||
}
|
||||
}
|
||||
Write-Host ("VM 内 : 供给={0} {1} (build {2}) PS={3}" -f $g.Provisioned, $g.OsCaption, $g.OsBuild, $g.GuestPS)
|
||||
@@ -235,9 +236,11 @@ switch ($Verb) {
|
||||
if ($g.Archives.Count -gt 0) {
|
||||
Write-Host ("沙盒归档 : {0} 个({1} MB 合计)" -f $g.Archives.Count, [math]::Round((($g.Archives | Measure-Object MB -Sum).Sum), 1))
|
||||
$g.Archives | Sort-Object MB -Descending | ForEach-Object { Write-Host (" {0,-52} {1,8} MB" -f $_.Name, $_.MB) }
|
||||
} else { Write-Host '沙盒归档 : (还没有,跑 Lab.ps1 backup)' }
|
||||
}
|
||||
else { Write-Host '沙盒归档 : (还没有,跑 Lab.ps1 backup)' }
|
||||
if ($g.LastLog) { Write-Host ("最近备份日志: {0}" -f $g.LastLog) }
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Host ("VM 内查询失败(可能还没起来):{0}" -f $_.Exception.Message) -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
@@ -337,8 +340,8 @@ switch ($Verb) {
|
||||
'test' {
|
||||
$map = [ordered]@{
|
||||
pester = @{ Path = 'tests\Run-Pester.ps1'; Args = @(); Name = 'Pester 套件' }
|
||||
zero = @{ Path = 'tests\Run-Tests.ps1'; Args = @(); Name = '零依赖套件' }
|
||||
e2e = @{ Path = 'tests\Run-E2E.ps1'; Args = @(); Name = '端到端验收' }
|
||||
zero = @{ Path = 'tests\Run-Tests.ps1'; Args = @(); Name = '零依赖套件' }
|
||||
e2e = @{ Path = 'tests\Run-E2E.ps1'; Args = @(); Name = '端到端验收' }
|
||||
}
|
||||
$pick = if ($Suite -eq 'all') { @($map.Keys) } else { @($Suite) }
|
||||
|
||||
@@ -367,7 +370,7 @@ switch ($Verb) {
|
||||
$color = if ($_.ExitCode -eq 0) { 'Green' } else { 'Red' }
|
||||
Write-Host (" {0,-14} 退出码 {1} 日志 {2}" -f $_.Suite, $_.ExitCode, $_.Log) -ForegroundColor $color
|
||||
}
|
||||
$bad = @($results | Where-Object ExitCode -ne 0)
|
||||
$bad = @($results | Where-Object ExitCode -NE 0)
|
||||
if ($bad.Count -gt 0) { throw ("有 {0} 套件失败" -f $bad.Count) }
|
||||
}
|
||||
|
||||
@@ -398,7 +401,7 @@ switch ($Verb) {
|
||||
|
||||
'reset' {
|
||||
if (-not $CheckpointName) { $CheckpointName = $cfg.CheckpointName }
|
||||
$snap = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -eq $CheckpointName
|
||||
$snap = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -EQ $CheckpointName
|
||||
if (-not $snap) { throw "找不到检查点 $CheckpointName" }
|
||||
Write-LabLog "回到检查点 $CheckpointName" 'STEP'
|
||||
Restore-VMSnapshot -VMSnapshot $snap -Confirm:$false
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('all','disk','vm','provision')][string]$Stage = 'all',
|
||||
[ValidateSet('all', 'disk', 'vm', 'provision')][string]$Stage = 'all',
|
||||
[switch]$Recreate,
|
||||
[switch]$ListImages,
|
||||
[int]$ImageIndex = 0
|
||||
@@ -54,14 +54,14 @@ function Get-IsoVolume {
|
||||
|
||||
function Get-ImageList {
|
||||
param([Parameter(Mandatory)][string]$IsoLetter)
|
||||
$wim = @('install.wim','install.esd') |
|
||||
$wim = @('install.wim', 'install.esd') |
|
||||
ForEach-Object { Join-Path "$IsoLetter`:\sources" $_ } |
|
||||
Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
|
||||
if (-not $wim) { throw "ISO ($IsoLetter`:) 里找不到 sources\install.wim|esd" }
|
||||
$info = & dism.exe /English /Get-WimInfo /WimFile:"$wim" 2>&1
|
||||
$list = @(); $cur = $null
|
||||
foreach ($line in $info) {
|
||||
if ($line -match '^Index\s*:\s*(\d+)') { if ($cur) { $list += $cur }; $cur = [ordered]@{ Index = [int]$Matches[1]; Name = ''; Size = '' } }
|
||||
if ($line -match '^Index\s*:\s*(\d+)') { if ($cur) { $list += $cur }; $cur = [ordered]@{ Index = [int]$Matches[1]; Name = ''; Size = '' } }
|
||||
elseif ($cur -and $line -match '^Name\s*:\s*(.+?)\s*$') { $cur.Name = $Matches[1] }
|
||||
elseif ($cur -and $line -match '^Size\s*:\s*(.+?)\s*$') { $cur.Size = $Matches[1] }
|
||||
}
|
||||
@@ -99,7 +99,7 @@ function New-LabSystemDisk {
|
||||
Write-LabLog "已创建动态 VHDX($($cfg.VhdxSizeGB) GB):$($cfg.VhdxPath)" 'STEP'
|
||||
}
|
||||
|
||||
$vhd = Mount-VHD -Path $cfg.VhdxPath -Passthru
|
||||
$vhd = Mount-VHD -Path $cfg.VhdxPath -Passthru
|
||||
$disk = $vhd | Get-Disk
|
||||
|
||||
if ($disk.PartitionStyle -eq 'RAW') {
|
||||
@@ -115,7 +115,7 @@ function New-LabSystemDisk {
|
||||
Write-LabLog '已完成 GPT 分区(ESP 类型已按 EFI System Partition 建立)与格式化' 'STEP'
|
||||
}
|
||||
|
||||
$efiPart = Get-Partition -DiskNumber $disk.Number | Where-Object GptType -eq $espGuid
|
||||
$efiPart = Get-Partition -DiskNumber $disk.Number | Where-Object GptType -EQ $espGuid
|
||||
$winPart = Get-Partition -DiskNumber $disk.Number | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' -and $_.Size -gt 1GB }
|
||||
if (-not $efiPart -or -not $winPart) { throw '分区布局不符合预期(ESP / Windows 分区没找到)' }
|
||||
$efiLetter = $efiPart.DriveLetter
|
||||
@@ -129,30 +129,32 @@ function New-LabSystemDisk {
|
||||
$di = Get-IsoVolume
|
||||
$isoLetter = ($di | Get-Volume).DriveLetter
|
||||
$il = Get-ImageList -IsoLetter $isoLetter
|
||||
$pick = $il.Images | Where-Object Index -eq $cfg.ImageIndex
|
||||
$pick = $il.Images | Where-Object Index -EQ $cfg.ImageIndex
|
||||
if (-not $pick) { throw "ISO 里没有索引 $($cfg.ImageIndex);可用:$($il.Images.Index -join ', ')" }
|
||||
Write-LabLog "展开映像 [$($pick.Index)] $($pick.Name) -> $winLetter`:(需要十几分钟)" 'STEP'
|
||||
$scratch = Get-LabPath 'scratch'
|
||||
$out = Get-LabPath 'logs\dism-apply.out'
|
||||
$err = Get-LabPath 'logs\dism-apply.err'
|
||||
$proc = Start-Process -FilePath 'dism.exe' -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err `
|
||||
-ArgumentList @('/English','/Apply-Image',"/ImageFile:$($il.WimPath)", "/Index:$($pick.Index)", "/ApplyDir:$winLetter`:\", "/ScratchDir:$scratch")
|
||||
-ArgumentList @('/English', '/Apply-Image', "/ImageFile:$($il.WimPath)", "/Index:$($pick.Index)", "/ApplyDir:$winLetter`:\", "/ScratchDir:$scratch")
|
||||
if ($proc.ExitCode -ne 0) { throw "DISM 展开失败,退出码 $($proc.ExitCode),见 $out / $err" }
|
||||
Write-LabLog '映像展开完成' 'STEP'
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-LabLog '系统盘上已有 Windows,跳过展开' 'WARN'
|
||||
}
|
||||
|
||||
# ---- 注入负载与无人值守应答文件 ----
|
||||
Write-LabLog '注入 7-Zip / PowerShell 7 / Pester / 供给脚本' 'STEP'
|
||||
$payloadSrc = Join-Path $PSScriptRoot 'payload'
|
||||
$guestLab = Join-Path "$winLetter`:\" ($cfg.GuestLabPath.TrimStart('\'))
|
||||
foreach ($item in '7zip','pwsh','Pester','provision.ps1') {
|
||||
$guestLab = Join-Path "$winLetter`:\" ($cfg.GuestLabPath.TrimStart('\'))
|
||||
foreach ($item in '7zip', 'pwsh', 'Pester', 'provision.ps1') {
|
||||
$src = Join-Path $payloadSrc $item
|
||||
$dst = Join-Path $guestLab ('payload\' + $item)
|
||||
if (Test-Path -LiteralPath $src) {
|
||||
$null = robocopy $src $dst /MIR /NFL /NDL /NJH /NJS /NP /R:1 /W:1
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-LabLog "负载缺失(跳过):$src" 'WARN'
|
||||
}
|
||||
}
|
||||
@@ -191,16 +193,17 @@ function New-LabVM {
|
||||
if (-not $vm) {
|
||||
Write-LabLog "创建虚拟机 $($cfg.VmName)(Gen2 / $($cfg.MemoryStartupGB) GB / $($cfg.CpuCount) vCPU)" 'STEP'
|
||||
$vm = New-VM -Name $cfg.VmName -Generation 2 -MemoryStartupBytes ($cfg.MemoryStartupGB * 1GB) `
|
||||
-VHDPath $cfg.VhdxPath -SwitchName $cfg.SwitchName
|
||||
-VHDPath $cfg.VhdxPath -SwitchName $cfg.SwitchName
|
||||
Set-VMProcessor -VMName $cfg.VmName -Count $cfg.CpuCount
|
||||
Set-VMFirmware -VMName $cfg.VmName -EnableSecureBoot Off
|
||||
Set-VM -Name $cfg.VmName -AutomaticStopAction TurnOff -AutomaticStartAction Nothing
|
||||
# 集成服务名随系统语言变化(中文是「来宾服务接口」),按状态启用而不是按名字找
|
||||
Get-VMIntegrationService -VMName $cfg.VmName | Where-Object { -not $_.Enabled } |
|
||||
ForEach-Object { Enable-VMIntegrationService -VMName $cfg.VmName -Name $_.Name }
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-LabLog "虚拟机 $($cfg.VmName) 已存在(状态 $($vm.State))" 'WARN'
|
||||
if (-not (Get-VMHardDiskDrive -VMName $cfg.VmName | Where-Object Path -eq $cfg.VhdxPath)) {
|
||||
if (-not (Get-VMHardDiskDrive -VMName $cfg.VmName | Where-Object Path -EQ $cfg.VhdxPath)) {
|
||||
Add-VMHardDiskDrive -VMName $cfg.VmName -Path $cfg.VhdxPath
|
||||
}
|
||||
}
|
||||
@@ -235,7 +238,7 @@ function Wait-LabProvision {
|
||||
|
||||
function New-LabCheckpoint {
|
||||
Assert-LabElevated -Why '创建 Hyper-V 检查点'
|
||||
$existing = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -eq $cfg.CheckpointName
|
||||
$existing = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -EQ $cfg.CheckpointName
|
||||
if ($existing) { Write-LabLog "检查点 $($cfg.CheckpointName) 已存在,跳过" 'WARN'; return }
|
||||
Checkpoint-VM -Name $cfg.VmName -SnapshotName $cfg.CheckpointName
|
||||
Write-LabLog "已创建检查点 $($cfg.CheckpointName)(Lab.ps1 -Verb reset 可回到此状态)" 'STEP'
|
||||
@@ -245,8 +248,8 @@ function New-LabCheckpoint {
|
||||
# 主流程
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ($Stage -in @('all','disk')) { New-LabSystemDisk }
|
||||
if ($Stage -in @('all','vm')) { New-LabVM }
|
||||
if ($Stage -in @('all','provision')) { Wait-LabProvision; New-LabCheckpoint }
|
||||
if ($Stage -in @('all', 'disk')) { New-LabSystemDisk }
|
||||
if ($Stage -in @('all', 'vm')) { New-LabVM }
|
||||
if ($Stage -in @('all', 'provision')) { Wait-LabProvision; New-LabCheckpoint }
|
||||
|
||||
Write-LabLog '搭建流程结束' 'STEP'
|
||||
@@ -34,7 +34,8 @@ function New-TextFile {
|
||||
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
||||
if ($Count -le 1) {
|
||||
Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Set-Content -LiteralPath $Path -Value (1..$Count | ForEach-Object { "$Content #$_" }) -Encoding UTF8
|
||||
}
|
||||
}
|
||||
@@ -73,7 +74,7 @@ New-TextFile (Join-Path $appC '数据 备份\记录.txt') '记录内容' -Count
|
||||
|
||||
# --- 4. 真 NTFS 连接点 -------------------------------------------------------
|
||||
$realTarget = Join-Path $Root 'AppMultiSlot\Data'
|
||||
$junction = Join-Path $Root 'JunctionToData'
|
||||
$junction = Join-Path $Root 'JunctionToData'
|
||||
if (-not (Test-Path -LiteralPath $junction)) {
|
||||
$null = New-Item -ItemType Junction -Path $junction -Target $realTarget -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -86,21 +87,22 @@ New-TextFile (Join-Path $cursor 'bottom.txt') 'deep content'
|
||||
Write-Host ("最长路径长度:{0} 字符" -f (Join-Path $cursor 'bottom.txt').Length)
|
||||
|
||||
# --- 6. 50 MB 大文件 ---------------------------------------------------------
|
||||
$bigDir = Join-Path $Root 'AppBig'
|
||||
$bigDir = Join-Path $Root 'AppBig'
|
||||
$bigFile = Join-Path $bigDir 'blob-50mb.bin'
|
||||
if (-not (Test-Path -LiteralPath $bigFile)) {
|
||||
New-Item -ItemType Directory -Force -Path $bigDir | Out-Null
|
||||
$fs = [IO.File]::Create($bigFile)
|
||||
try {
|
||||
$rng = [Random]::new(20260926)
|
||||
$rng = [Random]::new(20260926)
|
||||
$chunk = [byte[]]::new(1MB)
|
||||
for ($i = 0; $i -lt 50; $i++) { $rng.NextBytes($chunk); $fs.Write($chunk, 0, $chunk.Length) }
|
||||
} finally { $fs.Dispose() }
|
||||
}
|
||||
finally { $fs.Dispose() }
|
||||
}
|
||||
Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length/1MB,1))
|
||||
Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length / 1MB, 1))
|
||||
|
||||
# --- 7. 被占用文件(后台进程持句柄 90 秒后释放)-----------------------------
|
||||
$lockDir = Join-Path $Root 'AppLocked'
|
||||
$lockDir = Join-Path $Root 'AppLocked'
|
||||
$lockFile = Join-Path $lockDir 'locked.bin'
|
||||
New-Item -ItemType Directory -Force -Path $lockDir | Out-Null
|
||||
New-TextFile $lockFile 'this file is held open by another process'
|
||||
@@ -111,7 +113,7 @@ $holderLines = @(
|
||||
)
|
||||
$holderPath = 'C:\BakNRet-Lab\state\hold-lock.ps1'
|
||||
Set-Content -LiteralPath $holderPath -Value $holderLines -Encoding UTF8
|
||||
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$holderPath,$lockFile) -WindowStyle Hidden
|
||||
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $holderPath, $lockFile) -WindowStyle Hidden
|
||||
Write-Host "已启动占用者进程(持句柄 90 秒):$lockFile"
|
||||
|
||||
# --- 8. 「源不存在」条目对应的目录:故意不建 ---------------------------------
|
||||
@@ -121,6 +123,6 @@ Write-Host ''
|
||||
Write-Host '--- 沙盒源清单 ---'
|
||||
Get-ChildItem -LiteralPath $Root -Force | ForEach-Object {
|
||||
$files = @(Get-ChildItem -LiteralPath $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue)
|
||||
$mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2)
|
||||
$mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2)
|
||||
" {0,-24} {1,4} 个文件 {2,8} MB 连接点={3}" -f $_.Name, $files.Count, $mb, [bool]($_.Attributes -band [IO.FileAttributes]::ReparsePoint)
|
||||
}
|
||||
@@ -19,10 +19,10 @@
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$lab = 'C:\BakNRet-Lab'
|
||||
$logDir = Join-Path $lab 'logs'
|
||||
$lab = 'C:\BakNRet-Lab'
|
||||
$logDir = Join-Path $lab 'logs'
|
||||
$stateDir = Join-Path $lab 'state'
|
||||
New-Item -ItemType Directory -Force -Path $logDir, $stateDir | Out-Null
|
||||
|
||||
@@ -40,24 +40,24 @@ try {
|
||||
Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force
|
||||
|
||||
Step '3/7 工具上机器 PATH:7-Zip 与 PowerShell 7'
|
||||
$zipSrc = Join-Path $lab 'payload\7zip'
|
||||
$zipDst = 'C:\Program Files\7-Zip'
|
||||
$zipSrc = Join-Path $lab 'payload\7zip'
|
||||
$zipDst = 'C:\Program Files\7-Zip'
|
||||
$pwshSrc = Join-Path $lab 'payload\pwsh'
|
||||
$pwshDst = 'C:\Program Files\PowerShell\7'
|
||||
if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
|
||||
if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
|
||||
if (-not (Test-Path $pwshDst)) { robocopy $pwshSrc $pwshDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
|
||||
|
||||
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
|
||||
foreach ($p in @($zipDst, $pwshDst)) {
|
||||
if ($machinePath -notlike "*$p*") { $machinePath = $machinePath.TrimEnd(';') + ';' + $p }
|
||||
if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p }
|
||||
if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('Path', $machinePath, 'Machine')
|
||||
|
||||
Step '4/7 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 各一份)'
|
||||
$pesterSrc = Join-Path $lab 'payload\Pester\5.9.1'
|
||||
foreach ($dst in @("$env:ProgramFiles\WindowsPowerShell\Modules\Pester\5.9.1",
|
||||
"$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) {
|
||||
"$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) {
|
||||
if (-not (Test-Path $dst)) { robocopy $pesterSrc $dst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null }
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ try {
|
||||
New-ItemProperty -Path $scoobe -Name 'ScoobeSystemSettingEnabled' -Value 0 -PropertyType DWord -Force | Out-Null
|
||||
|
||||
Step '7/7 采集真机事实并落盘'
|
||||
$zipExe = Join-Path $zipDst '7z.exe'
|
||||
$zipExe = Join-Path $zipDst '7z.exe'
|
||||
$pwshExe = Join-Path $pwshDst 'pwsh.exe'
|
||||
$pwshVer = '缺失'
|
||||
if (Test-Path $pwshExe) { $pwshVer = (& $pwshExe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>&1) -join ' ' }
|
||||
@@ -100,8 +100,8 @@ try {
|
||||
CpuCount = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
|
||||
RamGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1)
|
||||
Drives = @(Get-Volume | Where-Object DriveLetter | ForEach-Object {
|
||||
[ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) }
|
||||
})
|
||||
[ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) }
|
||||
})
|
||||
}
|
||||
$facts | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $stateDir 'provisioned.json') -Encoding UTF8
|
||||
$facts.GetEnumerator() | ForEach-Object { Write-Host (" {0,-16} {1}" -f $_.Key, $_.Value) }
|
||||
|
||||
@@ -66,7 +66,8 @@ function Invoke-NativeTolerant {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
return @(& $FilePath @ArgumentList 2>&1)
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previous
|
||||
}
|
||||
}
|
||||
@@ -75,7 +76,8 @@ function Test-Scenario {
|
||||
if ($Ok) {
|
||||
$script:Passed++
|
||||
Write-Host (" [PASS] {0}" -f $Name) -ForegroundColor Green
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
$script:Failures += $Name
|
||||
Write-Host (" [FAIL] {0}{1}" -f $Name, $(if ($Detail) { ' —— ' + $Detail } else { '' })) -ForegroundColor Red
|
||||
}
|
||||
@@ -94,8 +96,8 @@ function Get-SecurityFingerprint {
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$sid = [System.Security.Principal.SecurityIdentifier]
|
||||
$aces = @($acl.GetAccessRules($true, $true, $sid) |
|
||||
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
|
||||
Sort-Object)
|
||||
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
|
||||
Sort-Object)
|
||||
return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' '))
|
||||
}
|
||||
|
||||
@@ -156,7 +158,8 @@ function Stop-VscodeProcesses {
|
||||
if ($path -and $path.StartsWith($AppRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 700
|
||||
@@ -184,7 +187,7 @@ function Remove-TreeHard {
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||
|
||||
$links = @(Get-ChildItem -LiteralPath $Path -Recurse -Force -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
||||
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
||||
foreach ($link in $links) {
|
||||
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir "{0}"' -f $link.FullName))
|
||||
Remove-BaknretJunction -Path $link.FullName
|
||||
@@ -207,7 +210,8 @@ function Remove-TreeHard {
|
||||
|
||||
if (Test-Path -LiteralPath $WorkRoot) {
|
||||
Get-ChildItem -LiteralPath $WorkRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object { Remove-TreeHard -Path $_.FullName }
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
|
||||
}
|
||||
$BackupDir = Join-Path $WorkRoot 'backups'
|
||||
@@ -242,10 +246,12 @@ if (-not $SkipScoop) {
|
||||
try {
|
||||
Invoke-Expression "& {$(Invoke-RestMethod -Uri 'https://get.scoop.sh')} -RunAsAdmin"
|
||||
Write-Host ('[A] 安装器退出码:{0}' -f $LASTEXITCODE)
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Test-Scenario 'A: 安装 scoop' $false $_.Exception.Message
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host '[A] scoop 已存在,跳过安装'
|
||||
}
|
||||
|
||||
@@ -299,9 +305,11 @@ $vscodeReady = [bool]$codeCmd
|
||||
|
||||
if ($vscodeReady) {
|
||||
Test-Scenario 'A: vscode 已安装(找到 CLI)' $true $codeCmd
|
||||
} elseif ($SkipScoop) {
|
||||
}
|
||||
elseif ($SkipScoop) {
|
||||
Write-Host (' [SKIP] 按 -SkipScoop 跳过 vscode({0} 不存在)' -f $vscodeCli) -ForegroundColor Yellow
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Test-Scenario 'A: vscode 已安装(找到 CLI)' $false $vscodeCli
|
||||
}
|
||||
|
||||
@@ -357,7 +365,7 @@ $sourceOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal
|
||||
Write-Host ('[B] 备份前 data 的属主:{0}' -f (Get-Acl -LiteralPath $bData).Owner)
|
||||
$currentSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
|
||||
Test-Scenario 'B: 现场造对了 —— 属主既不是当前账户、也不是新建对象的默认属主' `
|
||||
(($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) `
|
||||
(($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) `
|
||||
"owner=$sourceOwner current=$currentSid creatorDefault=$creatorOwner"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -398,7 +406,7 @@ $backup = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parame
|
||||
$backup.LastLog | ForEach-Object { ' ' + $_ }
|
||||
Test-Scenario '备份退出码 0' ($backup.ExitCode -eq 0) ('exit=' + $backup.ExitCode)
|
||||
Test-Scenario '每个条目都写了 .acl.json' (@(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count -ge $entries.Count) `
|
||||
('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count)
|
||||
('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 删源 → 恢复
|
||||
@@ -445,7 +453,8 @@ if ($vscodeReady) {
|
||||
[System.IO.File]::WriteAllText($probeFile, 'write probe')
|
||||
$writeOk = (Test-Path -LiteralPath $probeFile)
|
||||
Remove-Item -LiteralPath $probeFile -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
$detail = $_.Exception.Message
|
||||
}
|
||||
Test-Scenario 'A: vscode 的数据目录可写(新建文件成功)' $writeOk $detail
|
||||
@@ -455,9 +464,10 @@ if ($vscodeReady) {
|
||||
$expectedNormalized = $expected[$pair[1]] -replace ' P=(True|False) ', ' P='
|
||||
$actualNormalized = (Get-SecurityFingerprint -Path $pair[0]) -replace ' P=(True|False) ', ' P='
|
||||
Test-Scenario ("A: {0} 的安全指纹与备份前一致" -f $pair[1]) ($actualNormalized -eq $expectedNormalized) `
|
||||
("want: " + $expectedNormalized + " / got: " + $actualNormalized)
|
||||
("want: " + $expectedNormalized + " / got: " + $actualNormalized)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host ' [SKIP] vscode 没装上,A 段的功能断言跳过(-SkipScoop 或下载失败)' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
@@ -488,7 +498,7 @@ $negative = Join-Path $WorkRoot 'negative-data'
|
||||
& robocopy.exe $bData $negative /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
$negativeOwner = (Get-Acl -LiteralPath $negative).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
Test-Scenario 'B 负对照: 只搬文件时,属主变成"跑脚本的账户"而不再是原账户' `
|
||||
(($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) `
|
||||
(($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) `
|
||||
"negative=$negativeOwner creatorDefault=$creatorOwner"
|
||||
Write-Host (' 原属主 = {0}' -f $sourceOwner)
|
||||
Write-Host (' 恢复后属主 = {0}(应与原属主相同)' -f $restoredOwner)
|
||||
@@ -501,14 +511,16 @@ Write-Host ''
|
||||
$total = $script:Passed + $script:Failures.Count
|
||||
if ($script:Failures.Count -eq 0) {
|
||||
Write-Host ('ACL 演练:全部通过 {0} 项' -f $total) -ForegroundColor Green
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host ('ACL 演练:通过 {0} 项,失败 {1} 项' -f $script:Passed, $script:Failures.Count) -ForegroundColor Red
|
||||
foreach ($failure in $script:Failures) { Write-Host (' - ' + $failure) -ForegroundColor Red }
|
||||
}
|
||||
|
||||
if ($KeepWorkRoot) {
|
||||
Write-Host ('临时目录保留:{0}' -f $WorkRoot) -ForegroundColor Yellow
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Remove-TreeHard -Path $bRoot
|
||||
Remove-TreeHard -Path (Join-Path $WorkRoot 'negative-data')
|
||||
# 备份与日志留着,便于事后核对(归档可能有几百 MB,要腾空间就手工删这个目录)
|
||||
|
||||
@@ -38,8 +38,8 @@ $drillParams = [ordered]@{
|
||||
ConfigPath = $ConfigPath
|
||||
}
|
||||
if ($entries.Count -gt 0) { $drillParams['Entries'] = $entries }
|
||||
if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true }
|
||||
if ($AllowChanged) { $drillParams['AllowChanged'] = $true }
|
||||
if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true }
|
||||
if ($AllowChanged) { $drillParams['AllowChanged'] = $true }
|
||||
|
||||
Write-Host ("[lab] 恢复演练:{0} 个条目 -> {1}" -f $entries.Count, ($entries -join ' | '))
|
||||
& 'C:\BakNRet\tests\Restore-Drill.ps1' @drillParams
|
||||
@@ -29,8 +29,8 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host ("[lab] 包装器:PS {0},[Console]::OutputEncoding = {1}" -f $PSVersionTable.PSVersion, [Console]::OutputEncoding.WebName)
|
||||
Write-Host ("[lab] 套件:{0} {1}" -f $Suite, ($SuiteArgs -join ' '))
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
ToolOutput = 'quiet'
|
||||
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
|
||||
Encryption = @{ Enabled = $false; PasswordFile = 'C:\BakNRet-Lab\state\baknret.key'; EncryptHeaders = $true }
|
||||
DefaultExcludes = @('!Thumbs.db','!desktop.ini')
|
||||
DefaultExcludes = @('!Thumbs.db', '!desktop.ini')
|
||||
}
|
||||
@@ -5,18 +5,18 @@
|
||||
带连字符的键必须加引号(PSD1 会把它当减法);这里用中文键名,也统一加引号。
|
||||
#>
|
||||
@{
|
||||
AppMultiSlot = @{
|
||||
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' }
|
||||
AppMultiSlot = @{
|
||||
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' }
|
||||
DefaultConfig = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Config'; Description = '配置(含 .bak)' }
|
||||
CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' }
|
||||
CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' }
|
||||
}
|
||||
|
||||
AppFileSlot = @{
|
||||
AppFileSlot = @{
|
||||
Profile = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\profile.json'; Description = '单文件 Slot:归档内是名为 Profile 的文件' }
|
||||
Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' }
|
||||
Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' }
|
||||
}
|
||||
|
||||
'软件目录甲' = @{
|
||||
'软件目录甲' = @{
|
||||
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\软件 目录.甲'; Description = '中文 + 空格 + 点的路径' }
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\JunctionToData'; Description = '真 NTFS 连接点(指向 AppMultiSlot\Data)' }
|
||||
}
|
||||
|
||||
MissingApp = @{
|
||||
MissingApp = @{
|
||||
DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\MissingApp'; Description = '源不存在:备份跳过并记 missing-source,恢复仍知道目标位置' }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user