Imaging: defer bulk staging to first-logon Fetch (fresh mount) - Phase 1

WinPE maps Y: early then idles for minutes during the WIM apply; samba
deadtime drops the idle session, so the WinPE staging copies failed (bay
left with only site-config.json). Add Fetch-StagingPayload.ps1, run from the
unattend FirstLogonCommands at first logon on a FRESH share mount (full
Windows, no prior idle), to pull the shopfloor-setup tree + preinstall
bundle. Detailed per-item log (exit code, counts, timing, mount retries) at
C:\Logs\Fetch\ - the old WinPE staging was opaque.

- Fetch runs as Order 4, BEFORE wait-for-internet.ps1 (Order 5) which switches
  the bay to the production network and off the imaging LAN. So Fetch still
  reaches \172.16.9.1\enrollment.
- WinPE bulk staging kept as best-effort fail-fast fallback (Phase 1); the
  post-boot Fetch is now the authoritative path. Remove the WinPE bulk once
  validated. Heavy per-type payloads (CMM/Keyence/WaxTrace) stay in WinPE for
  now - Phase 2.
- startnet stages Fetch-StagingPayload.ps1 + writes fetch-source.txt
  (UNC/user/pass) for the post-boot mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-02 09:01:19 -04:00
parent f570e92847
commit a6fa21589b
3 changed files with 192 additions and 4 deletions

View File

@@ -0,0 +1,172 @@
# Fetch-StagingPayload.ps1 - post-boot bulk staging fetch (first-logon).
#
# WHY THIS EXISTS
# WinPE used to stage the whole shopfloor-setup tree + preinstall bundle to
# the target disk DURING the WinPE phase. But WinPE maps the enrollment share
# (Y:) early, then idles for many minutes while the full Windows image applies.
# Samba's `deadtime` drops idle sessions, so by the time WinPE reached the
# copies the Y: mount was dead and most copies failed (symptom: a bay with
# only site-config.json staged, then nothing). Doing the bulk copy here - at
# first logon, in full Windows, on a FRESH share mount with no prior idle -
# sidesteps that entirely.
#
# WHEN IT RUNS
# The unattend FirstLogonCommands runs this BEFORE the PowerShell 7 MSI install
# (which needs C:\PreInstall\installers\powershell7\) and before
# Run-ShopfloorSetup.ps1 (which needs C:\Enrollment\shopfloor-setup\). So this
# must populate both trees before those steps fire.
#
# WHAT IT FETCHES (generic bulk - Phase 1)
# \\<server>\enrollment\shopfloor-setup\Run-ShopfloorSetup.ps1 -> C:\Enrollment\
# \\<server>\enrollment\shopfloor-setup\{backup_lockdown.bat,Shopfloor,common,
# _ntlars-backups,gea-shopfloor-<pctype>} -> C:\Enrollment\shopfloor-setup\
# \\<server>\enrollment\pre-install\{preinstall.json,installers,udc-backups}
# -> C:\PreInstall\
# (Heavy per-type payloads - CMM/Keyence/WaxTrace - are still staged in WinPE
# for now; Phase 2 moves those here too.)
#
# LOGGING
# Verbose transcript + a per-item table to C:\Logs\Fetch\. Every robocopy logs
# its exit code, file/dir counts, byte total, and elapsed time, so a failed
# fetch is fully diagnosable (unlike the old opaque WinPE staging).
#
# Always exits 0 - a fetch failure must not abort the FirstLogonCommands chain;
# the log carries the truth and Run-ShopfloorSetup surfaces missing pieces.
$ErrorActionPreference = 'Continue'
# --- Logging setup ---
$logDir = 'C:\Logs\Fetch'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$logFile = Join-Path $logDir "fetch-staging-$stamp.log"
try { Start-Transcript -Path $logFile -Append -Force | Out-Null } catch {}
function Log {
param([string]$Message, [string]$Level = 'INFO')
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "[$ts] [$Level] $Message"
}
Log "================================================================"
Log "=== Fetch-StagingPayload start (PID $PID) ==="
Log "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Log "Host: $env:COMPUTERNAME"
Log "================================================================"
# --- Resolve the share source + creds (written by startnet to fetch-source.txt;
# falls back to the historical defaults if absent) ---
$shareUnc = '\\172.16.9.1\enrollment'
$shareUser = 'pxe-upload'
$sharePass = 'pxe'
$srcFile = 'C:\Enrollment\fetch-source.txt'
if (Test-Path -LiteralPath $srcFile) {
# Format: line1=UNC, line2=user, line3=pass
$lines = @(Get-Content -LiteralPath $srcFile -ErrorAction SilentlyContinue)
if ($lines.Count -ge 1 -and $lines[0].Trim()) { $shareUnc = $lines[0].Trim() }
if ($lines.Count -ge 2 -and $lines[1].Trim()) { $shareUser = $lines[1].Trim() }
if ($lines.Count -ge 3 -and $lines[2].Trim()) { $sharePass = $lines[2].Trim() }
Log "fetch-source.txt: UNC=$shareUnc user=$shareUser"
} else {
Log "fetch-source.txt absent - using defaults: UNC=$shareUnc user=$shareUser"
}
# --- pc-type (drives which gea-shopfloor-<type> dir to fetch) ---
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA 0).Trim()
}
Log "PC type: $(if ($pcType) { $pcType } else { '(none)' })"
# --- Status push (best-effort) ---
$pxeStatusLib = 'C:\Enrollment\shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1'
# (lib not fetched yet on first run; ignore if absent)
if (Test-Path $pxeStatusLib) { try { . $pxeStatusLib; Send-PxeStatus -Stage 'Fetch-StagingPayload: starting' -StageIndex 1 -StageTotal 8 } catch {} }
# --- Mount the share fresh (use Z:; retry to ride out a brief blip) ---
$drive = 'Z:'
function Mount-Share {
& net use $drive /delete /y 2>$null | Out-Null
$r = & net use $drive $shareUnc /user:$shareUser $sharePass /persistent:no 2>&1
return ($LASTEXITCODE -eq 0)
}
$mounted = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
Log "Mounting $shareUnc as $drive (attempt $attempt/5)..."
if (Mount-Share) { $mounted = $true; Log "Mounted OK"; break }
Log "Mount failed (exit $LASTEXITCODE) - waiting 10s" 'WARN'
Start-Sleep -Seconds 10
}
if (-not $mounted) {
Log "Could not mount $shareUnc after 5 attempts - ABORTING fetch. Bay will be under-provisioned; re-run this script once the share is reachable." 'ERROR'
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Fetch helper: robocopy one item, log exit + counts + timing ---
$results = @()
function Fetch-Item {
param(
[string]$Label,
[string]$SrcDir, # under $drive
[string]$DstDir,
[string[]]$Files, # named files for a flat copy; empty = whole-dir /E
[switch]$Recurse # /E whole directory
)
$src = Join-Path $drive $SrcDir
if (-not (Test-Path -LiteralPath $src)) {
Log "[SKIP] $Label - source not on share: $src" 'WARN'
$script:results += [pscustomobject]@{ Item=$Label; Exit='n/a'; Result='SOURCE-MISSING' }
return
}
if (-not (Test-Path -LiteralPath $DstDir)) { New-Item -ItemType Directory -Path $DstDir -Force | Out-Null }
$args = @($src, $DstDir)
if ($Recurse) { $args += '/E' } else { $args += $Files }
$args += @('/R:2','/W:3','/NFL','/NDL','/NP')
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Log "[COPY] $Label : robocopy $src -> $DstDir $(if ($Recurse){'/E'}else{$Files -join ','})"
$out = & robocopy @args 2>&1
$rc = $LASTEXITCODE
$sw.Stop()
# robocopy 0-7 = success, 8+ = failure
$ok = ($rc -lt 8)
# pull the summary counts robocopy prints
$summary = ($out | Select-String -Pattern 'Files :|Dirs :|Bytes :' ) -join ' | '
Log "[$(if($ok){'OK'}else{'FAIL'})] $Label exit=$rc time=$([math]::Round($sw.Elapsed.TotalSeconds,1))s $summary"
$script:results += [pscustomobject]@{ Item=$Label; Exit=$rc; Result=$(if($ok){'OK'}else{'FAIL'}) }
}
# --- Generic bulk fetch ---
$ENR = 'C:\Enrollment'
$SFD = 'C:\Enrollment\shopfloor-setup'
$PIN = 'C:\PreInstall'
Fetch-Item -Label 'Run-ShopfloorSetup.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Run-ShopfloorSetup.ps1')
Fetch-Item -Label 'backup_lockdown.bat' -SrcDir 'shopfloor-setup' -DstDir $SFD -Files @('backup_lockdown.bat')
Fetch-Item -Label 'Shopfloor baseline' -SrcDir 'shopfloor-setup\Shopfloor' -DstDir (Join-Path $SFD 'Shopfloor') -Recurse
Fetch-Item -Label 'common' -SrcDir 'shopfloor-setup\common' -DstDir (Join-Path $SFD 'common') -Recurse
Fetch-Item -Label '_ntlars-backups' -SrcDir 'shopfloor-setup\_ntlars-backups' -DstDir (Join-Path $SFD '_ntlars-backups') -Recurse
if ($pcType) {
Fetch-Item -Label "type:$pcType" -SrcDir "shopfloor-setup\$pcType" -DstDir (Join-Path $SFD $pcType) -Recurse
}
# preinstall bundle
Fetch-Item -Label 'preinstall.json' -SrcDir 'pre-install' -DstDir $PIN -Files @('preinstall.json')
Fetch-Item -Label 'preinstall installers' -SrcDir 'pre-install\installers' -DstDir (Join-Path $PIN 'installers') -Recurse
Fetch-Item -Label 'udc-backups' -SrcDir 'pre-install\udc-backups' -DstDir (Join-Path $PIN 'udc-backups') -Recurse
# --- Unmount ---
& net use $drive /delete /y 2>$null | Out-Null
# --- Summary table ---
Log "================================================================"
Log "FETCH SUMMARY:"
foreach ($r in $results) { Log (" {0,-28} exit={1,-4} {2}" -f $r.Item, $r.Exit, $r.Result) }
$failed = @($results | Where-Object { $_.Result -eq 'FAIL' })
if ($failed.Count -gt 0) {
Log "$($failed.Count) item(s) FAILED: $(( $failed | ForEach-Object { $_.Item }) -join ', ')" 'ERROR'
} else {
Log "All fetched items OK." 'INFO'
}
Log "=== Fetch-StagingPayload complete ==="
try { Stop-Transcript | Out-Null } catch {}
exit 0