Files
Shuery 187d2759fd 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 层。这条要改行为、且影响真实名录的解析
    结果,留给你拍板,不在本提交里动手。
2026-09-27 09:46:08 +08:00

426 lines
22 KiB
PowerShell
Raw Permalink 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
BakNRet 隔离测试环境(Hyper-V 真机级 VM)的日常入口。
.DESCRIPTION
与 New-BakNRetLab.ps1 的分工:那个负责**搭**,这个负责**用**。
动词:
status 看 VM 状态、检查点、供给事实、沙盒归档与最近日志
start/stop 启停 VM
wait 等 VM 内供给完成(首次搭建后)
sync 把当前仓库快照推进 VM(排除 Backups\ logs\ .git\ .tools\),并装好 Pester
seed 在 VM 里生成「带刺」的沙盒假数据(真 NTFS 连接点、被占用文件、长路径、中文路径…)
backup 在 VM 里用沙盒清单/配置真跑 Backup.ps1(可选 -DryRun)
restore 用真实归档做恢复演练(Restore-Drill.ps1),逐字节对拍
acl-test 安全描述符演练:scoop 装的 vscode 备份/恢复后仍可读写;ProgramData 那种
「属主 + CREATOR OWNER」的目录恢复后属主必须仍是原账户(另有负对照)
test 在 VM 里跑仓库自带的测试套件(pester / zero / e2e / all)
shell 打开到 VM 的交互式 PowerShell Direct 会话
console 打印 VM 内的供给日志与最新备份日志
checkpoint 打检查点(默认带时间戳;-CheckpointName 可指定)
reset 回到 clean-baseline 检查点(秒回干净状态)
destroy 删除 VM 与系统盘(需要 -Confirm)
一切都在 VM 内进行:宿主机的仓库、Backups\、logs\ 不会被这套流程写入。
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 status
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 sync
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 seed -Force
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 backup
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 restore
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 test -Suite all
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[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',
# 恢复演练要处理的条目(写法同 BackupList.txt 的一行)
[string[]]$Entries,
[switch]$DryRun,
[switch]$Force,
[switch]$AcceptWarnings,
[switch]$KeepWork,
# acl-test 专用:跳过"装 scoop + scoop install vscode"(省掉几百 MB 下载,
# 只验证 ProgramData 那段的属主 / CREATOR OWNER)
[switch]$SkipScoop,
[string]$CheckpointName,
[switch]$Confirm
)
$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"
$guestBackupDir = 'C:\BakNRet-Lab\Backups'
Assert-LabElevated -Why "Hyper-V 操作与 PowerShell Direct 都需要管理员(动词:$Verb)"
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
function Get-VmSummary {
$vm = Get-LabVm
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
UptimeText = "$([int]$vm.Uptime.TotalMinutes) 分钟"
}
}
function Invoke-GuestScriptFile {
<# .SYNOPSIS 在 VM 里用 pwsh 跑脚本文件,回传退出码与日志尾部。 #>
param(
[Parameter(Mandatory)][string]$ScriptPath,
# 不设 Mandatory:不需要参数的套件会传空数组,Mandatory 会拒绝空数组绑定
[string[]]$ScriptArgs = @(),
[Parameter(Mandatory)][string]$Tag,
[int]$TailLines = 30
)
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$logPath = "C:\BakNRet-Lab\logs\$Tag-$stamp.log"
# 参数用 JSON 传:数组直接经 Invoke-Command -ArgumentList 过去会退化成嵌套数组,
# 到 VM 里 Start-Process -ArgumentList 就会报「无法转换为 System.String」。
$argsJson = if (@($ScriptArgs).Count -eq 0) { '[]' } else { ConvertTo-Json -InputObject @($ScriptArgs) -Compress }
if (@($ScriptArgs).Count -eq 1 -and -not $argsJson.StartsWith('[') -and -not $argsJson.StartsWith('{')) { $argsJson = "[$argsJson]" }
return Invoke-LabCommand -ScriptBlock {
param($script, $argsJson, $logPath, $tailLines)
# ConvertFrom-Json 把 JSON 数组当成「一个对象」写出,直接 @(...) 会套成嵌套数组,
# 传到 Start-Process -ArgumentList 就报「无法转换为 System.String」。显式枚举摊平。
$scriptArgs = @()
if ($argsJson) {
$parsed = ConvertFrom-Json -InputObject $argsJson
$scriptArgs = @($parsed | ForEach-Object { [string]$_ })
}
# 子进程被重定向的 stdout 是**控制台代码页**(中文 Windows 上是 GBK/936),
# 用 -Encoding UTF8 读会整片乱码;而且 PS7 的 Get-Content -Encoding 不接受
# Encoding 对象。这里按「替换字符更少」的胜出者解码。
function Read-TextTail([string]$path, [int]$lines) {
if (-not (Test-Path -LiteralPath $path)) { return @() }
$bytes = [IO.File]::ReadAllBytes($path)
$asUtf8 = [Text.Encoding]::UTF8.GetString($bytes)
$asAnsi = [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage).GetString($bytes)
$badUtf8 = 0; foreach ($ch in $asUtf8.ToCharArray()) { if ($ch -eq [char]0xFFFD) { $badUtf8++ } }
$badAnsi = 0; foreach ($ch in $asAnsi.ToCharArray()) { if ($ch -eq [char]0xFFFD) { $badAnsi++ } }
$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
$out = $logPath
$err = "$logPath.err"
$p = Start-Process -FilePath 'pwsh.exe' -ArgumentList $all -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
[pscustomobject]@{
ExitCode = $p.ExitCode
LogPath = $out
Tail = @(Read-TextTail $out $tailLines)
ErrTail = @(Read-TextTail $err 10)
}
} -ArgumentList $ScriptPath, $argsJson, $logPath, $TailLines
}
function Invoke-LabSync {
$zip = Get-LabPath 'stage\repo.zip'
$sevenZip = Get-HostSevenZip
Write-LabLog "打包仓库快照:$($cfg.RepoRoot)(排除 Backups\ logs\ .git\ .tools\)" 'STEP'
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 }
Write-LabLog ("快照大小 {0} MB" -f [math]::Round((Get-Item -LiteralPath $zip).Length / 1MB, 2))
Write-LabLog '推送到 VM(Copy-VMFile,走 VMBus)' 'STEP'
Copy-LabFileToGuest -SourcePath $zip -DestinationPath "$($cfg.GuestLabPath)\stage\repo.zip"
Write-LabLog '在 VM 内解开到 C:\BakNRet 并装好 Pester' 'STEP'
$info = Invoke-LabCommand -ScriptBlock {
param($guestRepo, $guestLab)
$sevenZip = 'C:\Program Files\7-Zip\7z.exe'
if (-not (Test-Path -LiteralPath $sevenZip)) { $sevenZip = Join-Path $guestLab 'payload\7zip\7z.exe' }
if (Test-Path -LiteralPath $guestRepo) { Remove-Item -LiteralPath $guestRepo -Recurse -Force }
New-Item -ItemType Directory -Force -Path $guestRepo | Out-Null
$null = & $sevenZip x "$guestLab\stage\repo.zip" "-o$guestRepo" -y
$pesterDst = Join-Path $guestRepo '.tools\modules\Pester\5.9.1'
New-Item -ItemType Directory -Force -Path $pesterDst | Out-Null
robocopy "$guestLab\payload\Pester\5.9.1" $pesterDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
[pscustomobject]@{
SyncedAt = (Get-Date).ToString('s')
Files = (Get-ChildItem -LiteralPath $guestRepo -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count
HasBackup = (Test-Path (Join-Path $guestRepo 'Backup.ps1'))
HasPester = (Test-Path (Join-Path $pesterDst 'Pester.psd1'))
}
} -ArgumentList $cfg.GuestRepoPath, $cfg.GuestLabPath
Write-LabLog ("同步完成:{0} 个文件,Backup.ps1={1},Pester={2}" -f $info.Files, $info.HasBackup, $info.HasPester) 'STEP'
return $info
}
function Show-GuestOutput {
param($Result, [switch]$Quiet)
if (-not $Quiet) {
foreach ($line in @($Result.Tail)) { Write-Host " $line" }
foreach ($line in @($Result.ErrTail)) { if ($line) { Write-Host " ! $line" -ForegroundColor Yellow } }
}
$color = if ($Result.ExitCode -eq 0) { 'Green' } else { 'Red' }
Write-Host (" 退出码 = {0}" -f $Result.ExitCode) -ForegroundColor $color
}
# ---------------------------------------------------------------------------
# 动词
# ---------------------------------------------------------------------------
switch ($Verb) {
'status' {
$s = Get-VmSummary
if (-not $s) {
Write-Host 'VM 不存在。先跑 tools\lab\New-BakNRetLab.ps1 搭建。' -ForegroundColor Yellow
break
}
Write-Host ''
Write-Host ('== BakNRet 隔离测试环境 ==') -ForegroundColor Cyan
Write-Host ("VM : {0} [{1}] 已运行 {2}" -f $s.Name, $s.State, $s.UptimeText)
Write-Host ("规格 : Gen{0} / {1} vCPU / {2} GB / Default Switch" -f $s.Gen, $s.Cpu, $s.MemoryGB)
Write-Host ("实验室目录: {0}" -f $cfg.LabRoot)
Write-Host ("VHDX : {0} ({1} GB 实际占用)" -f $cfg.VhdxPath, [math]::Round((Get-Item -LiteralPath $cfg.VhdxPath).Length / 1GB, 2))
$snaps = @(Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue)
Write-Host ("检查点 : {0}" -f $(if ($snaps) { ($snaps | ForEach-Object { "$($_.Name) [$($_.CreationTime.ToString('MM-dd HH:mm'))]" }) -join ', ' } else { '(无)' }))
if ($s.State -eq 'Running') {
try {
$g = Invoke-LabCommand -RetrySeconds 30 -ScriptBlock {
$ok = Test-Path 'C:\BakNRet-Lab\state\provision.ok'
$os = Get-CimInstance Win32_OperatingSystem
$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) } })
}
$src = 'C:\BakNRet-Lab\sources'
[pscustomobject]@{
Provisioned = $ok
OsBuild = $os.BuildNumber
OsCaption = $os.Caption
GuestPS = $PSVersionTable.PSVersion.ToString()
RepoFiles = $(if (Test-Path 'C:\BakNRet') { (Get-ChildItem 'C:\BakNRet' -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count } else { 0 })
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)
}
}
Write-Host ("VM 内 : 供给={0} {1} (build {2}) PS={3}" -f $g.Provisioned, $g.OsCaption, $g.OsBuild, $g.GuestPS)
Write-Host ("仓库副本 : C:\BakNRet {0} 个文件" -f $g.RepoFiles)
Write-Host ("沙盒源数据 : {0} MB" -f $g.SourceMB)
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)' }
if ($g.LastLog) { Write-Host ("最近备份日志: {0}" -f $g.LastLog) }
}
catch {
Write-Host ("VM 内查询失败(可能还没起来):{0}" -f $_.Exception.Message) -ForegroundColor Yellow
}
}
Write-Host ''
}
'start' {
$vm = Get-LabVm
if (-not $vm) { throw 'VM 不存在,先跑 New-BakNRetLab.ps1' }
if ($vm.State -ne 'Running') { Start-VM -Name $cfg.VmName; $null = Wait-LabVMRunning -TimeoutSeconds 180 }
Write-LabLog "VM 状态:$((Get-LabVm).State)" 'STEP'
}
'stop' {
$vm = Get-LabVm
if ($vm -and $vm.State -eq 'Running') {
Write-LabLog '正常关机(走集成服务)' 'STEP'
Stop-VM -Name $cfg.VmName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
if ((Get-LabVm).State -ne 'Off') { Write-LabLog '未关机,强制断电' 'WARN'; Stop-VM -Name $cfg.VmName -TurnOff -Force }
}
Write-LabLog "VM 状态:$((Get-LabVm).State)" 'STEP'
}
'wait' {
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalMinutes -lt 30) {
if (Test-LabGuestReady) {
Write-LabLog ("VM 已就绪(等待 {0} 分钟)" -f [math]::Round($sw.Elapsed.TotalMinutes, 1)) 'STEP'
$facts = Invoke-LabCommand -ScriptBlock { Get-Content -Encoding UTF8 'C:\BakNRet-Lab\state\provisioned.json' -Raw }
Write-Host $facts
break
}
Start-Sleep -Seconds 10
}
if (-not (Test-LabGuestReady)) { throw '等待超时:VM 内供给仍未完成' }
}
'sync' { $null = Invoke-LabSync }
'seed' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
$argList = @()
if ($Force) { $argList += '-Force' }
Write-LabLog '在 VM 内生成沙盒假数据' 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath $guestFixture -ScriptArgs $argList -Tag 'fixtures' -TailLines 20
Show-GuestOutput $r
}
'backup' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
$argList = @('-BackupListPath', $guestList, '-ConfigPath', $guestConfig)
if ($DryRun) { $argList += '-DryRun' }
if ($Force) { $argList += '-Force' }
if ($AcceptWarnings) { $argList += '-AcceptWarnings' }
Write-LabLog "在 VM 内跑 Backup.ps1(DryRun=$DryRun,Force=$Force)" 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\Backup.ps1" -ScriptArgs $argList -Tag 'backup' -TailLines 40
Show-GuestOutput $r
}
'restore' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
if (-not $Entries -or $Entries.Count -eq 0) {
$Entries = @(
'AppMultiSlot', 'AppFileSlot', '软件目录甲', 'JunctionToData',
'C:\BakNRet-Lab\sources\AppBig', 'C:\BakNRet-Lab\sources\AppDeep'
)
}
# 数组参数不能跨进程传(-File 只会绑第一个值),改用 ';' 分隔的纯文本,
# 由 payload\run-drill.ps1 在 VM 内做真正的数组绑定
$entriesCsv = (@($Entries) | ForEach-Object { [string]$_ }) -join ';'
$argList = @('-BackupDir', $guestBackupDir, '-ConfigPath', $guestConfig, '-EntriesCsv', $entriesCsv)
if ($KeepWork) { $argList += '-KeepWorkRoot' }
Write-LabLog ("恢复演练:{0} 个条目" -f @($Entries).Count) 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\tools\lab\payload\run-drill.ps1" -ScriptArgs $argList -Tag 'drill' -TailLines 45
Show-GuestOutput $r
}
'acl-test' {
Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync
$argList = @('-RepoPath', $cfg.GuestRepoPath, '-WorkRoot', 'C:\BakNRet-Lab\acl')
if ($SkipScoop) { $argList += '-SkipScoop' }
if ($KeepWork) { $argList += '-KeepWorkRoot' }
Write-LabLog '安全描述符演练:scoop 装的 vscode + ProgramData 属主 / CREATOR OWNER' 'STEP'
$r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\tools\lab\payload\run-acl-scenario.ps1" -ScriptArgs $argList -Tag 'acl' -TailLines 60
Show-GuestOutput $r
# 其它动词都不回传 guest 退出码(只有 test 会扔异常),这个必须扔:
# 否则演练失败时宿主侧仍然退出 0,等于没有门禁。
if ($r.ExitCode -ne 0) {
throw ("ACL 演练失败(退出码 {0}),VM 内日志 {1}" -f $r.ExitCode, $r.LogPath)
}
}
'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 = '端到端验收' }
}
$pick = if ($Suite -eq 'all') { @($map.Keys) } else { @($Suite) }
Write-LabLog '先把当前工作树同步进 VM' 'STEP'
$null = Invoke-LabSync
$results = @()
foreach ($key in $pick) {
$item = $map[$key]
$argList = @($item.Args)
if ($key -eq 'e2e' -and $KeepWork) { $argList += '-KeepWorkRoot' }
Write-LabLog ("跑 {0}({1})" -f $item.Name, $item.Path) 'STEP'
# 走 UTF-8 包装器:测试自己抓子进程输出时按 UTF-8 读回,
# 而 VM 的控制台输出编码是 ANSI(936),直接跑会有 8 项中文断言失败(见 README「已知问题」)
$wrapperPath = "$($cfg.GuestRepoPath)\tools\lab\payload\run-suite-utf8.ps1"
$suiteArgs = @("$($cfg.GuestRepoPath)\$($item.Path)") + $argList
$r = Invoke-GuestScriptFile -ScriptPath $wrapperPath -ScriptArgs $suiteArgs -Tag "test-$key" -TailLines 8
Show-GuestOutput $r -Quiet
foreach ($line in @($r.Tail) | Where-Object { $_ -match '全部通过|通过 \d+ 项,失败|通过\s*\d+' }) { Write-Host " $line" }
$results += [pscustomobject]@{ Suite = $item.Name; ExitCode = $r.ExitCode; Log = $r.LogPath }
}
Write-Host ''
Write-Host '== 套件结果 ==' -ForegroundColor Cyan
$results | ForEach-Object {
$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)
if ($bad.Count -gt 0) { throw ("有 {0} 套件失败" -f $bad.Count) }
}
'shell' {
Write-LabLog '进入 VM(PowerShell Direct)。退出用 exit。' 'STEP'
$cred = Get-LabCredential
Enter-PSSession -VMName $cfg.VmName -Credential $cred
}
'console' {
$r = Invoke-LabCommand -ScriptBlock {
$out = @()
foreach ($f in 'C:\BakNRet-Lab\logs\provision.log') {
if (Test-Path $f) { $out += "===== $f ====="; $out += @(Get-Content $f -Tail 40 -Encoding UTF8) }
}
$latest = Get-ChildItem 'C:\BakNRet-Lab\logs' -Filter 'backup-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1
if ($latest) { $out += "===== $($latest.FullName) ====="; $out += @(Get-Content $latest.FullName -Tail 60 -Encoding UTF8) }
$out
}
$r | ForEach-Object { Write-Host $_ }
}
'checkpoint' {
if (-not $CheckpointName) { $CheckpointName = 'lab-' + (Get-Date -Format 'MMdd-HHmm') }
Checkpoint-VM -Name $cfg.VmName -SnapshotName $CheckpointName
Write-LabLog "已创建检查点 $CheckpointName" 'STEP'
}
'reset' {
if (-not $CheckpointName) { $CheckpointName = $cfg.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
$null = Wait-LabVMRunning -TimeoutSeconds 240
Write-LabLog ("VM 状态:{0}" -f (Get-LabVm).State) 'STEP'
}
'destroy' {
if (-not $Confirm) { throw '这会删除 VM 与系统盘。确认请加 -Confirm。' }
$vm = Get-LabVm
if ($vm) {
if ($vm.State -ne 'Off') { Stop-VM -Name $cfg.VmName -TurnOff -Force }
Remove-VM -Name $cfg.VmName -Force
Write-LabLog "已删除虚拟机 $($cfg.VmName)" 'STEP'
}
if (Test-Path -LiteralPath $cfg.VhdxPath) {
Remove-Item -LiteralPath $cfg.VhdxPath -Force
Write-LabLog "已删除系统盘 $($cfg.VhdxPath)" 'STEP'
}
Write-LabLog '($LabRoot 下的日志与凭据保留,便于排查)' 'WARN'
}
}