Files
BakNRet/tools/lab/New-BakNRetLab.ps1
T
Shuery e10503be76 fix: 让 5.1 真正可用(显式编码 + 原生 stderr 处理 + .psd1 夹具带 BOM)
上一提交让 5.1 能解析源码,但 Unit 与 Smoke 在 5.1 上仍然是红的。根因是三类彼此
无关的 5.1/7 行为差,全部实测确认:

1) 不写 -Encoding 时,5.1 的 Get-Content / Set-Content 默认是 ANSI,7 是 UTF-8。
   症状是 UTF-8 字节被按 GBK 解出「璇存槑」这类乱码。62 处补上显式 -Encoding UTF8。
   用 AST 而不是正则定位,避免把注释里的散文也改掉。

2) .psd1 夹具用无 BOM 写,而引擎的 .psd1 读取器(Import-PowerShellDataFile)只能靠
   BOM 判断编码、没有参数可传,于是 5.1 按 ANSI 解。40 处夹具改为带 BOM 写 —— 这正是
   .editorconfig 里 [*.psd1] charset = utf-8-bom 本来就要求的,是夹具违反了自己的约定。
   .cmd 批次文件刻意保持无 BOM:cmd.exe 会被 BOM 弄坏。

3) 5.1 在 $ErrorActionPreference = Stop 下会把原生命令写到 stderr 的内容升级成终止性
   NativeCommandError,7 改了这条。takeown/icacls 的 ACL 复位调用、以及 test.ps1 自己
   调子进程的地方,都需要在 Continue 下跑。

验收:test.ps1 9/9 全绿(Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍)。

已知未处理(留待后续提交):tools/lab/** 里还有若干「原生命令 + 2>&1 + Stop」的同类
写法(takeown / icacls / scoop / code / Get-WimInfo)。它们要 Hyper-V 实验机才跑得到,
不在验收门槛内。
2026-09-26 22:16:04 +08:00

252 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
从零搭出 BakNRet 的 Hyper-V 隔离测试 VM(真机级:真 NTFS、真 ACL、真连接点、真重启)。
.DESCRIPTION
全流程无人值守、不需要点任何安装向导,也不需要 VM 的图形界面:
1. disk —— 挂载 Windows ISO,建 80 GB 动态 VHDX,按 UEFI 规范 GPT 分区,
用 DISM 把 install.wim 的指定版本展开进去,注入 7-Zip / PowerShell 7 /
Pester / 供给脚本,写入 C:\Windows\Panther\unattend.xml,最后 bcdboot 写引导;
2. vm —— 建 Gen2 虚拟机(8 GB / 8 vCPU、Default Switch、"来宾服务接口"打开、
关闭安全启动以便离线注入的引导链可用),挂载系统盘并启动;
3. provision —— 等首次登录的供给脚本跑完,读回 VM 自报的真机事实,然后打检查点
clean-baseline(之后 Lab.ps1 -Verb reset 可秒回到干净状态)。
幂等:已存在的 VHDX / VM 会复用,除非显式加 -Recreate。
.PARAMETER ListImages
只打印 ISO 里的映像索引清单,不建任何东西。
.PARAMETER Stage
all(默认)/ disk / vm / provision,可单独重跑某一段排查问题。
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages
.EXAMPLE
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1
#>
[CmdletBinding()]
param(
[ValidateSet('all','disk','vm','provision')][string]$Stage = 'all',
[switch]$Recreate,
[switch]$ListImages,
[int]$ImageIndex = 0
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Lab-Common.ps1')
$cfg = Get-LabConfig
if ($ImageIndex -gt 0) { $cfg.ImageIndex = $ImageIndex }
# ---------------------------------------------------------------------------
# ISO 与映像清单
# ---------------------------------------------------------------------------
function Get-IsoVolume {
$di = Get-DiskImage -ImagePath $cfg.IsoPath -ErrorAction SilentlyContinue
if (-not $di -or -not $di.Attached) { $di = Mount-DiskImage -ImagePath $cfg.IsoPath -PassThru }
Start-Sleep -Milliseconds 1200
return $di
}
function Get-ImageList {
param([Parameter(Mandatory)][string]$IsoLetter)
$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 = '' } }
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] }
}
if ($cur) { $list += $cur }
return [pscustomobject]@{ WimPath = $wim; Images = $list }
}
if ($ListImages) {
Assert-LabElevated -Why '挂载 ISO 需要管理员'
$di = Get-IsoVolume
$letter = ($di | Get-Volume).DriveLetter
$il = Get-ImageList -IsoLetter $letter
Write-Host "映像文件:$($il.WimPath)" -ForegroundColor Cyan
$il.Images | ForEach-Object { " [{0}] {1} {2}" -f $_.Index, $_.Name, $_.Size }
return
}
# ---------------------------------------------------------------------------
# 1. 系统盘
# ---------------------------------------------------------------------------
function New-LabSystemDisk {
Assert-LabElevated -Why '创建/分区 VHDX 与 DISM 展开映像'
$espGuid = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}'
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $cfg.VhdxPath) | Out-Null
if ((Test-Path -LiteralPath $cfg.VhdxPath) -and $Recreate) {
Write-LabLog "删除已有 VHDX:$($cfg.VhdxPath)" 'WARN'
$mounted = Get-VHD -Path $cfg.VhdxPath -ErrorAction SilentlyContinue
if ($mounted -and $mounted.Attached) { Dismount-VHD -Path $cfg.VhdxPath }
Remove-Item -LiteralPath $cfg.VhdxPath -Force
}
if (-not (Test-Path -LiteralPath $cfg.VhdxPath)) {
New-VHD -Path $cfg.VhdxPath -SizeBytes ($cfg.VhdxSizeGB * 1GB) -Dynamic | Out-Null
Write-LabLog "已创建动态 VHDX($($cfg.VhdxSizeGB) GB):$($cfg.VhdxPath)" 'STEP'
}
$vhd = Mount-VHD -Path $cfg.VhdxPath -Passthru
$disk = $vhd | Get-Disk
if ($disk.PartitionStyle -eq 'RAW') {
# Initialize-Disk 会顺手塞一个 MSR,先删掉,按 UEFI 规范自己建:ESP(300MB FAT32) + Windows(剩余 NTFS)
Initialize-Disk -Number $disk.Number -PartitionStyle GPT -Confirm:$false | Out-Null
Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue |
Where-Object { $_.Type -eq 'Reserved' } | ForEach-Object { Remove-Partition -DiskNumber $_.DiskNumber -PartitionNumber $_.PartitionNumber -Confirm:$false }
$efi = New-Partition -DiskNumber $disk.Number -Size 300MB -GptType $espGuid -AssignDriveLetter
Format-Volume -Partition $efi -FileSystem FAT32 -NewFileSystemLabel 'System' -Confirm:$false -Force | Out-Null
$win = New-Partition -DiskNumber $disk.Number -UseMaximumSize -AssignDriveLetter
Format-Volume -Partition $win -FileSystem NTFS -NewFileSystemLabel 'Windows' -Confirm:$false -Force | Out-Null
Write-LabLog '已完成 GPT 分区(ESP 类型已按 EFI System Partition 建立)与格式化' 'STEP'
}
$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
$winLetter = $winPart.DriveLetter
if (-not $efiLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $efiPart.PartitionNumber -NewDriveLetter 'S'; $efiLetter = 'S' }
if (-not $winLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $winPart.PartitionNumber -NewDriveLetter 'W'; $winLetter = 'W' }
Write-LabLog "ESP = $efiLetter`:,Windows = $winLetter`:" 'STEP'
# ---- 展开映像 ----
if (-not (Test-Path -LiteralPath "$winLetter`:\Windows\System32\ntoskrnl.exe")) {
$di = Get-IsoVolume
$isoLetter = ($di | Get-Volume).DriveLetter
$il = Get-ImageList -IsoLetter $isoLetter
$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")
if ($proc.ExitCode -ne 0) { throw "DISM 展开失败,退出码 $($proc.ExitCode),见 $out / $err" }
Write-LabLog '映像展开完成' 'STEP'
} 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') {
$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 {
Write-LabLog "负载缺失(跳过):$src" 'WARN'
}
}
# 口令:随机生成,只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json
$password = New-LabPassword
$credPath = Save-LabCredential -Password $password
Write-LabLog "已生成 VM 凭据($credPath)" 'STEP'
$unattendSrc = Get-Content -LiteralPath (Join-Path $payloadSrc 'unattend.xml') -Raw -Encoding UTF8
$unattendXml = $unattendSrc.Replace('__LABPASSWORD__', $password)
$panther = Join-Path "$winLetter`:\" 'Windows\Panther'
New-Item -ItemType Directory -Force -Path $panther | Out-Null
[System.IO.File]::WriteAllText((Join-Path $panther 'unattend.xml'), $unattendXml, [System.Text.UTF8Encoding]::new($true))
Write-LabLog "已写入 $panther\unattend.xml" 'STEP'
# ---- 引导 ----
Write-LabLog 'bcdboot 写 UEFI 引导' 'STEP'
& bcdboot.exe "$winLetter`:\Windows" /s "$efiLetter`:" /f UEFI | ForEach-Object { Write-LabLog " $_" }
if ($LASTEXITCODE -ne 0) { throw "bcdboot 失败,退出码 $LASTEXITCODE" }
$bootMgr = Join-Path "$efiLetter`:\" 'EFI\Microsoft\Boot\bootmgfw.efi'
if (-not (Test-Path -LiteralPath $bootMgr)) { throw "ESP 上没有 bootmgfw.efi:$bootMgr" }
Write-LabLog "引导文件就位:$bootMgr" 'STEP'
Dismount-VHD -Path $cfg.VhdxPath
Write-LabLog '系统盘已完成并卸载' 'STEP'
}
# ---------------------------------------------------------------------------
# 2. 虚拟机
# ---------------------------------------------------------------------------
function New-LabVM {
Assert-LabElevated -Why '创建/配置 Hyper-V 虚拟机'
$vm = Get-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
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 {
Write-LabLog "虚拟机 $($cfg.VmName) 已存在(状态 $($vm.State))" 'WARN'
if (-not (Get-VMHardDiskDrive -VMName $cfg.VmName | Where-Object Path -eq $cfg.VhdxPath)) {
Add-VMHardDiskDrive -VMName $cfg.VmName -Path $cfg.VhdxPath
}
}
$vm = Get-LabVm
if ($vm.State -ne 'Running') {
Write-LabLog '启动虚拟机' 'STEP'
Start-VM -Name $cfg.VmName
if (-not (Wait-LabVMRunning -TimeoutSeconds 180)) { throw '虚拟机没有进入 Running' }
}
Write-LabLog "虚拟机状态:$((Get-LabVm).State)" 'STEP'
}
# ---------------------------------------------------------------------------
# 3. 供给与检查点
# ---------------------------------------------------------------------------
function Wait-LabProvision {
Assert-LabElevated -Why 'PowerShell Direct 需要管理员'
Write-LabLog '等待 VM 内供给脚本完成(首次启动要几分钟)' 'STEP'
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalMinutes -lt 30) {
if (Test-LabGuestReady) {
Write-LabLog "供给完成,耗时 $([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
return
}
Start-Sleep -Seconds 15
}
throw '等待供给超时(30 分钟);用 Lab.ps1 -Verb console 到 VM 里看一眼 provision.log'
}
function New-LabCheckpoint {
Assert-LabElevated -Why '创建 Hyper-V 检查点'
$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'
}
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
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'