Files
pxe-server/scripts/Capture-SiteContent.ps1
cproudlock 04db6d6605 Generic build for other GE sites: no WJ data, one shopfloor type, Windows USB builder
Everything West Jefferson specific moved out to a separate repo
(pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers,
CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the
WJ preinstall list, and the WJ shopfloor PC types (Collections,
NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace,
Display, PartMarker) with GE-Enforce, machine numbers and the desktop
layout. The part-marker bench tools moved to their own repo. Dead
scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models,
mirror-from-gold, pull-bios, startnet-template.cmd.

Shopfloor is one generic type, gea-shopfloor-standard, with today's
timing:
- PPKG at first logon (unattend Order 10, unchanged).
- Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup.
- A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once
  after the post-PPKG reboot. It also clears AutoLogonCount after the
  ShopFloor handover (the Users-level monitor can be denied that HKLM
  write) and removes the Intune Sync task when done.
- Both lists ship empty. A site adds entries and installers.
- WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal
  carry only the generic payload. The webapp's shopfloor sub-menu editor
  is gone, and the imaging tiles name the new stages.
- The playbook deploys the empty lists only if missing. It now deploys
  the PowerShell 7 MSI the shopfloor unattend installs, which it never
  did before.

Blancco site settings move into the webapp (Updates > Blancco releases):
business location, licensing Wi-Fi SSID and password. They are rendered
into the served preferences.xml and applied to every staged release, and
are seeded from site-vars (site_name, blancco_wifi_ssid,
blancco_wifi_password) only while none exist. The repo XML carries no
site values; the plaintext Wi-Fi password is gone from it.

New site_name and site_timezone settings: report and evidence-pack site
name, server clock and webapp time zone. No more West Jefferson defaults
in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the
server address filled in at download instead of WJ's GE address.

USB on Windows:
- scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It
  writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus
  payload) with diskpart and reads every file back by SHA-256.
- Tested with Windows PowerShell 5.1 in the win11 VM, and the layout
  installs and first-boots in the pxetest VM.
- scripts/stage-usb-share.sh stages the build folder on a share.
- site-content/ is optional for both builders.

KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment,
Windows only, with a site-vars.yml guide.

Checks: webapp tests pass (5 suites); the unattend lint and playbook
syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names
or PC types in playbook/, webapp/, scripts/ or autoinstall/.
2026-10-16 20:37:22 -04:00

229 lines
11 KiB
PowerShell

#
# Capture-SiteContent.ps1 - Pull the site content from a running PXE server
# into the pxe-usb build folder, on a Windows PC.
#
# The Windows twin of scripts/capture-site-content.sh. A new PXE server
# needs content that neither the repo nor the MCL image import brings; it
# exists only on a working server. This pulls, into the build folder:
# site-content\shared\Applications\extra\ Office, Adobe, OpenText, Java,
# fonts, Wi-Fi profile, Zscaler (every unattend installs from it;
# pxe-status\ is left out, the playbook deploys it)
# site-content\images\<image>\Applications\ each image's own files and
# folders there: GE CA certificates, the Engineering Layer payload
# (without Install-EngineeringLayer.ps1, which the repo carries)
# site-content\links.txt the links images keep there
# site-content\SHA256SUMS checked by the playbook
# bios-staging\ *.exe and models.txt from _shared\BIOS
# boot-files\boot.wim
# boot-tools\blancco\ the Blancco files already in that folder
#
# How: ONE ssh session (one password prompt) runs a small script on the
# server that collects those files, hashes them there (SHA-256) and streams
# them back as a tar archive. Here the archive is unpacked into a scratch
# folder and every file is checked against the server's hashes, and against
# what FAT32 can hold (under 4 GiB, no two names that differ only in case).
# Only when all of that passes does the new content replace the old. The
# server is only read.
#
# Needs: Windows 10/11 (ssh.exe and tar.exe are built in), SSH access to the
# server as pxe (from the GE network use the server's GE address).
#
# Usage: double-click Capture-SiteContent.cmd in the build folder (asks for
# the server address), or:
# .\scripts\Capture-SiteContent.ps1 -Server 10.x.x.x
#
# Exit codes: 0 captured and verified, 1 failed (the old content is kept).
[CmdletBinding()]
param(
[string]$Server = '',
[string]$User = 'pxe',
[string]$PayloadRoot = '',
[switch]$Interactive
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2
$RepoRoot = Split-Path -Parent $PSScriptRoot
if (-not $PayloadRoot) { $PayloadRoot = $RepoRoot }
$PayloadRoot = (Resolve-Path -LiteralPath $PayloadRoot).Path
$FatMax = [int64]4GB - 1
function Say([string]$m) { Write-Host $m }
function Done([int]$code) {
if ($Interactive) { Write-Host ''; Read-Host 'Press Enter to close' | Out-Null }
exit $code
}
function Fail([string]$m) { Write-Host "ERROR: $m" -ForegroundColor Red; Done 1 }
trap { Write-Host "ERROR: $_" -ForegroundColor Red; Done 1 }
foreach ($exe in 'ssh.exe', 'tar.exe') {
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { Fail "$exe not found (Windows 10 1803 or later has both)" }
}
if (-not $Server) {
if (-not $Interactive) { Fail 'give -Server (the PXE server address; from the GE network its GE address)' }
Say 'Address of the running PXE server to copy from.'
Say 'From the GE network use its GE address; on the imaging switch 172.16.9.1.'
$Server = (Read-Host 'Server').Trim()
if (-not $Server) { Fail 'no server given' }
}
if ($Server -notmatch '^[A-Za-z0-9.\-:]+$') { Fail "not a host name or address: $Server" }
# The Blancco files to refresh: whatever is already in boot-tools\blancco
# (the server also holds files its playbook builds; those are not pulled).
$blanccoDir = Join-Path $PayloadRoot 'boot-tools\blancco'
$blancco = @()
if (Test-Path -LiteralPath $blanccoDir) {
$blancco = @(Get-ChildItem -LiteralPath $blanccoDir -Force | ForEach-Object { $_.Name } |
Where-Object { $_ -match '^[A-Za-z0-9._+-]+$' })
}
# --- the script that runs on the server (bash, stdout = the tar stream) -----
$remote = @'
set -euo pipefail
SHARE=${CAPTURE_SHARE:-/srv/samba/winpeapps}
WWW=${CAPTURE_WWW:-/var/www/html}
ST=$(mktemp -d /tmp/pxe-capture.XXXXXX)
trap 'rm -rf "$ST"' EXIT
SC=$ST/site-content
mkdir -p "$SC/shared/Applications" "$SC/images" "$ST/bios-staging" "$ST/boot-files" "$ST/boot-tools/blancco"
[ -d "$SHARE/_shared/Applications/extra" ] && ln -s "$SHARE/_shared/Applications/extra" "$SC/shared/Applications/extra"
printf '# image<TAB>name in Deploy/Applications<TAB>link target under winpeapps/\n' >"$SC/links.txt"
for apps in "$SHARE"/*/Deploy/Applications; do
[ -d "$apps" ] || continue
img=${apps#"$SHARE"/}; img=${img%%/*}
case "$img" in _*) continue ;; esac
for e in "$apps"/* "$apps"/.[!.]*; do
[ -e "$e" ] || [ -L "$e" ] || continue
n=${e##*/}
if [ -L "$e" ]; then
t=$(readlink "$e")
case "$t" in
"$SHARE"/_shared/*) printf '%s\t%s\t%s\n' "$img" "$n" "${t#"$SHARE"/}" >>"$SC/links.txt" ;;
*) echo "WARNING: $img/Deploy/Applications/$n links outside _shared ($t); not captured" >&2 ;;
esac
continue
fi
# a real BPRT folder is an old import's copy of the vendor folder
[ "${n,,}" = bprt ] && continue
mkdir -p "$SC/images/$img/Applications"
ln -s "$e" "$SC/images/$img/Applications/$n"
done
done
for f in "$SHARE"/_shared/BIOS/*.exe "$SHARE"/_shared/BIOS/models.txt; do
[ -f "$f" ] && ln -s "$f" "$ST/bios-staging/${f##*/}"
done
[ -f "$WWW/win11/sources/boot.wim" ] && ln -s "$WWW/win11/sources/boot.wim" "$ST/boot-files/boot.wim"
for n in "$@"; do
[ -e "$WWW/blancco/$n" ] && ln -s "$WWW/blancco/$n" "$ST/boot-tools/blancco/$n"
done
EXCL=(--exclude=./site-content/shared/Applications/extra/pxe-status --exclude=Install-EngineeringLayer.ps1)
echo "pxe-capture: hashing on the server (a few minutes for about 12 GB)..." >&2
( cd "$ST" && find -L . -type f ! -path './site-content/shared/Applications/extra/pxe-status/*' \
! -name Install-EngineeringLayer.ps1 ! -name capture-SHA256SUMS -print0 |
sort -z | xargs -0 -r sha256sum ) >"$ST/capture-SHA256SUMS.tmp"
mv "$ST/capture-SHA256SUMS.tmp" "$ST/capture-SHA256SUMS"
echo "pxe-capture: $(wc -l <"$ST/capture-SHA256SUMS") files; sending..." >&2
tar -chf - -C "$ST" "${EXCL[@]}" .
'@
$scratch = Join-Path $PayloadRoot '.capture-new'
$old = Join-Path $PayloadRoot '.capture-old'
foreach ($d in $scratch, $old) {
if (Test-Path -LiteralPath $d) { Remove-Item -LiteralPath $d -Recurse -Force }
}
New-Item -ItemType Directory -Path $scratch | Out-Null
$remoteFile = Join-Path ([IO.Path]::GetTempPath()) ('pxe-capture-' + [guid]::NewGuid().ToString('N') + '.sh')
[IO.File]::WriteAllText($remoteFile, ($remote -replace "`r", ''), (New-Object System.Text.UTF8Encoding($false)))
Say '============================================'
Say 'PXE site content capture (Windows)'
Say '============================================'
Say "Server : $User@$Server"
Say "Folder : $PayloadRoot"
Say "Blancco : $($blancco.Count) known files"
Say ''
Say "[1/3] Copying from the server (enter the $User password when asked)..."
# cmd's pipe passes bytes unchanged; a PowerShell pipe would re-encode the tar.
$names = ($blancco | ForEach-Object { "'" + $_ + "'" }) -join ' '
$cmdline = "ssh.exe -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 $User@$Server `"bash -s -- $names`" < `"$remoteFile`" | tar.exe -xf - -C `"$scratch`""
& cmd.exe /c $cmdline
$tarCode = $LASTEXITCODE
Remove-Item -LiteralPath $remoteFile -Force -ErrorAction SilentlyContinue
$sumFile = Join-Path $scratch 'capture-SHA256SUMS'
if (-not (Test-Path -LiteralPath $sumFile)) {
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
Fail "nothing usable arrived (ssh/tar exit $tarCode): wrong address or password, or the server is not reachable. Nothing was changed."
}
Say ''
Say '[2/3] Checking every file against the server''s SHA-256...'
$lines = @([IO.File]::ReadAllLines($sumFile) | Where-Object { $_ })
$want = @{}
$lower = @{}
$errors = New-Object System.Collections.Generic.List[string]
foreach ($l in $lines) {
if ($l -notmatch '^([0-9a-f]{64}) \./(.+)$') { $errors.Add("bad line in the server's list: $l"); continue }
$rel = $Matches[2]
$key = $rel.ToLowerInvariant()
if ($lower.ContainsKey($key)) { $errors.Add("names differ only in case (one would overwrite the other): $rel / $($lower[$key])") }
$lower[$key] = $rel
$want[$rel] = $Matches[1]
}
$i = 0
foreach ($rel in $want.Keys) {
$i++
if ($i % 25 -eq 0) { Write-Progress -Activity 'Verifying' -Status $rel -PercentComplete ([int](100 * $i / $want.Count)) }
$p = Join-Path $scratch ($rel -replace '/', '\')
if (-not (Test-Path -LiteralPath $p -PathType Leaf)) { $errors.Add("missing: $rel"); continue }
$f = Get-Item -LiteralPath $p
if ($f.Length -gt $FatMax) { $errors.Add("4 GiB or more (FAT32 cannot hold it): $rel") }
if ((Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash.ToLowerInvariant() -ne $want[$rel]) { $errors.Add("differs from the server: $rel") }
}
Write-Progress -Activity 'Verifying' -Completed
if ($errors.Count -gt 0) {
Say "$($errors.Count) problem(s); nothing was changed:"
$errors | Select-Object -First 20 | ForEach-Object { Say " $_" }
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
Fail 'capture not usable (long paths? see https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation)'
}
Say " $($want.Count) files match"
# site-content\SHA256SUMS in the format the playbook checks (sha256sum -c,
# paths relative to site-content), from the server's own hashes.
$sc = New-Object System.Collections.Generic.List[string]
foreach ($l in $lines) {
if ($l -match '^([0-9a-f]{64}) \./site-content/(.+)$') { $sc.Add("$($Matches[1]) ./$($Matches[2])") }
}
[IO.File]::WriteAllText((Join-Path $scratch 'site-content\SHA256SUMS'), (($sc -join "`n") + "`n"), (New-Object System.Text.UTF8Encoding($false)))
Say ''
Say '[3/3] Replacing the old content...'
New-Item -ItemType Directory -Path $old | Out-Null
function Swap([string]$rel) {
$new = Join-Path $scratch $rel
if (-not (Test-Path -LiteralPath $new)) { return }
$cur = Join-Path $PayloadRoot $rel
if (Test-Path -LiteralPath $cur) {
$bak = Join-Path $old $rel
New-Item -ItemType Directory -Path (Split-Path -Parent $bak) -Force | Out-Null
Move-Item -LiteralPath $cur -Destination $bak
}
New-Item -ItemType Directory -Path (Split-Path -Parent $cur) -Force | Out-Null
Move-Item -LiteralPath $new -Destination $cur
Say " $rel"
}
Swap 'site-content'
Swap 'bios-staging'
Swap 'boot-files\boot.wim'
foreach ($n in $blancco) { Swap (Join-Path 'boot-tools\blancco' $n) }
Remove-Item -LiteralPath $old -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
$size = (Get-ChildItem -LiteralPath (Join-Path $PayloadRoot 'site-content') -Recurse -File | Measure-Object Length -Sum).Sum
Say ''
Say ('Done: site-content {0:N1} GB, {1} files. Build the USB next (Build-PxeUsb.cmd).' -f ($size / 1GB), $sc.Count)
Done 0