Verified MCL uploads: hash every file, repair over HTTP, gate the import

Small Control JSONs have arrived empty or wrong more than once, and the old
check compared sizes only and skipped missing files. A copy of
Deploy/Control on the dev PC still holds two 0-byte JSONs.

Upload-Image.ps1 (tested on Windows PowerShell 5.1 in the win11 VM):
- refuses an unfinished MCL cache: exactly one <OS>-Production.tag (MCL
  writes it last), no Control file newer than it, selected OS matches,
  every Control/Tools JSON non-empty and valid
- checks payload against the SHA-256 MCL's own Control JSONs publish. MCL
  resumes downloads onto old files (FileMode.Append) and then trusts any
  file of the right size: the September junk-tail corruption. Offers to
  delete the bad cache files so MCL downloads them again
- hashes every file into a manifest, robocopies, POSTs the manifest
- re-sends bad small files over HTTP (no proxy), large ones by deleting
  the bad copy and robocopy /J (robocopy skips a same-size, same-time file
  as "modified" even with /IS /IT)

Webapp:
- services/upload_verify.py hashes the upload on the server's own disk in
  a background thread; reports missing, differing, bad-in-MCL-cache and
  stray files; PUT /api/upload/file writes temp, checks hash, renames
- import of the upload folder only after a passed verification, with
  every file unchanged (size, mtime, ctime, inode), no stray files, and
  the target the OS allows (LTSC: Shop Floor only; else Standard or
  Engineer; unknown OS: none). Import, verify and PUT share one lock
- paths matched case-insensitively against the disk, as Samba does
- import page shows the upload, MCL run, verification and stray files
  with a remove button; record of each import kept on the image

Reviewed adversarially; all 11 confirmed findings fixed with tests.
42 new unit tests. VM test: clean upload; same-size same-mtime damage on
3 files repaired in one round; stray file blocks import until removed;
Shop Floor refused for a Pro upload; import records and clears the
folder; missing tag, 0-byte JSON, junk-head WIM and late Control file
each refused with nothing uploaded.
This commit is contained in:
cproudlock
2026-10-16 10:09:40 -04:00
parent 576955785d
commit bca04f4ca2
6 changed files with 1826 additions and 203 deletions

View File

@@ -1,8 +1,25 @@
#
# Upload-Image.ps1 — Copy MCL cached image to the PXE server
# Upload-Image.ps1 - Copy the Media Creator Lite cache to the PXE server,
# then PROVE every file arrived intact.
#
# Reads user_selections.json to upload only the selected OS, matching
# packages, and config files. Drivers are EXCLUDED by default.
# 1. Checks the MCL cache before sending anything:
# - exactly one <OS>-Production.tag at the cache root (MCL writes it only
# when a run finishes; none = an unfinished or interrupted cache)
# - no Control file newer than that tag (MCL still writing / crashed)
# - the OS in OperatingSystem.json is the one in user_selections.json
# - every Control / Tools JSON is non-empty and parses
# - every WIM, update and driver pack hashes to the SHA-256 MCL's own
# Control JSONs publish (MCL resumes downloads onto old files and then
# trusts any file of the right size, so a cache can hold junk)
# 2. Hashes every file it will send into upload-manifest.json.
# 3. Copies with robocopy as before (SMB share \\<server>\image-upload).
# 4. POSTs the manifest to the PXE webapp, which hashes the files on the
# server's own disk (no Windows SMB cache in the way) and lists any file
# that is missing or differs.
# 5. Re-sends those files over HTTP (small files; the server writes a temp
# file, checks the hash, then renames it) or robocopy /J /IS (large
# files), and verifies again, up to -MaxRounds times.
# The webapp's Image Import only accepts an upload that passed step 4.
#
# Usage:
# .\Upload-Image.ps1 (selected OS + packages, no drivers)
@@ -10,19 +27,23 @@
# .\Upload-Image.ps1 -CachePath "D:\MCL\Cache" (custom cache location)
# .\Upload-Image.ps1 -Server 172.16.9.1 (custom server IP)
#
# After upload, use the PXE webapp (http://172.16.9.1:9009) to import
# the uploaded content into the desired image type.
# Exit codes: 0 verified, 1 failed (details printed), 2 MCL cache not usable.
#
param(
[string]$CachePath = "C:\ProgramData\GEAerospace\MediaCreator\Cache",
[string]$Server = "172.16.9.1",
[int]$WebPort = 9009,
[string]$User = "pxe-upload",
[string]$Pass = "pxe",
[int]$MaxRounds = 3,
[switch]$IncludeDrivers
)
$ErrorActionPreference = "Stop"
$Share = "\\$Server\image-upload"
$ApiBase = "http://${Server}:$WebPort"
$ExcludedDeployDirs = @("Operating Systems", "Out-of-box Drivers", "Packages")
function Format-Size {
param([long]$Bytes)
@@ -33,7 +54,79 @@ function Format-Size {
function Resolve-DestDir {
param([string]$Dir)
return ($Dir -replace '^\*destinationdir\*\\?', '')
$d = $Dir -replace '^\*destinationdir\*\\?', ''
return (($d -replace '^[A-Za-z]:\\', '').Trim('\'))
}
function Write-Step {
param([string]$Text)
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host $Text -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
}
function Stop-Cache {
# The cache itself is unusable: nothing was sent.
param([string[]]$Lines)
Write-Host ""
foreach ($l in $Lines) { Write-Host "ERROR: $l" -ForegroundColor Red }
Write-Host "Nothing was uploaded." -ForegroundColor Red
exit 2
}
function Read-JsonFile {
param([string]$Path)
return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json)
}
# HTTP to the webapp with HttpWebRequest and NO proxy: the work PC's system
# proxy (Zscaler) must not see traffic to the isolated imaging LAN.
# Returns @{ Status = <int>; Body = <string> }; never throws on HTTP errors.
function Invoke-Pxe {
param([string]$Method, [string]$Path, [byte[]]$Body, [string]$InFile,
[string]$ContentType = "application/json", [int]$TimeoutSec = 120)
$req = [System.Net.HttpWebRequest]::Create("$ApiBase$Path")
$req.Method = $Method
$req.Proxy = $null
$req.Timeout = $TimeoutSec * 1000
$req.ReadWriteTimeout = $TimeoutSec * 1000
$req.KeepAlive = $false
if ($Body -or $InFile) {
$req.ContentType = $ContentType
if ($InFile) { $req.ContentLength = (Get-Item -LiteralPath $InFile).Length }
else { $req.ContentLength = $Body.Length }
try { $rs = $req.GetRequestStream() } catch { return @{ Status = 0; Body = $_.Exception.Message } }
try {
if ($InFile) {
$fs = [System.IO.File]::OpenRead($InFile)
try { $fs.CopyTo($rs) } finally { $fs.Dispose() }
} else {
$rs.Write($Body, 0, $Body.Length)
}
} finally { $rs.Dispose() }
}
try {
$resp = $req.GetResponse()
} catch {
# A .NET method call wraps the WebException in a
# MethodInvocationException: look at the inner one.
$ex = $_.Exception
while ($ex -and -not ($ex -is [System.Net.WebException])) { $ex = $ex.InnerException }
if (-not $ex -or -not $ex.Response) { return @{ Status = 0; Body = $_.Exception.Message } }
$resp = $ex.Response
}
try {
$sr = New-Object System.IO.StreamReader($resp.GetResponseStream())
$text = $sr.ReadToEnd()
$sr.Dispose()
return @{ Status = [int]$resp.StatusCode; Body = $text }
} finally { $resp.Close() }
}
function Get-ErrorText {
param($Resp)
try { return ($Resp.Body | ConvertFrom-Json).error } catch { return $Resp.Body }
}
Write-Host ""
@@ -43,17 +136,60 @@ Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# --- Validate source paths ---
$CachePath = $CachePath.TrimEnd('\')
$DeployPath = Join-Path $CachePath "Deploy"
$ToolsPath = Join-Path (Split-Path $CachePath -Parent) "Tools"
if (-not (Test-Path $ToolsPath -PathType Container)) {
$ToolsPath = "C:\ProgramData\GEAerospace\MediaCreator\Tools"
}
$SourcesZip = Join-Path $CachePath "Boot\Sources.zip"
$ControlPath = Join-Path $DeployPath "Control"
if (-not (Test-Path $DeployPath -PathType Container)) {
Write-Host "ERROR: Deploy directory not found at $DeployPath" -ForegroundColor Red
Write-Host " .\Upload-Image.ps1 -CachePath ""D:\Path\To\Cache""" -ForegroundColor Yellow
exit 1
exit 2
}
# --- MCL finished? One tag at the cache root, written at the very end ---
$tags = @(Get-ChildItem -LiteralPath $CachePath -Filter "*.tag" -File -ErrorAction SilentlyContinue)
if ($tags.Count -eq 0) {
Stop-Cache @("No MCL completion tag (*.tag) in $CachePath.",
"MCL writes it only when a run finishes: the cache is incomplete or the run was interrupted.",
"Run Media Creator Lite again and let it finish.")
}
if ($tags.Count -gt 1) {
Stop-Cache (@("More than one MCL tag in ${CachePath}:") + ($tags | ForEach-Object { " $($_.Name)" }) +
@("MCL leaves exactly one. Run Media Creator Lite again."))
}
$tag = $tags[0]
$tagBytes = [System.IO.File]::ReadAllBytes($tag.FullName)
if ($tagBytes.Length -lt 8) { Stop-Cache @("MCL tag $($tag.Name) is damaged ($($tagBytes.Length) bytes). Run MCL again.") }
# MCL writes DateTime.Now.Ticks (local time) into the tag (TagFactory).
try { $tagTime = New-Object DateTime ([BitConverter]::ToInt64($tagBytes, 0)) }
catch { Stop-Cache @("MCL tag $($tag.Name) holds no valid time. Run MCL again.") }
$tagOs = $tag.BaseName -replace '-(Production|QA)$', ''
$late = @(Get-ChildItem -LiteralPath $ControlPath -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -gt $tagTime.AddSeconds(5) })
if ($late.Count -gt 0) {
Stop-Cache (@("These Control files are newer than the MCL finish tag ($tagTime):") +
($late | ForEach-Object { " $($_.Name) $($_.LastWriteTime)" }) +
@("MCL was still writing or crashed. Run Media Creator Lite again and let it finish."))
}
# --- Every Control / Tools JSON must be non-empty and parse ---
$badJson = @()
foreach ($dir in @($ControlPath, $ToolsPath)) {
if (-not (Test-Path $dir -PathType Container)) { continue }
foreach ($j in Get-ChildItem -LiteralPath $dir -Filter "*.json" -File -Recurse) {
if ($j.Length -eq 0) { $badJson += "$($j.FullName) (0 bytes)"; continue }
try { $null = Read-JsonFile $j.FullName } catch { $badJson += "$($j.FullName) (not valid JSON)" }
}
}
if ($badJson.Count -gt 0) {
Stop-Cache (@("Broken JSON in the MCL cache:") + ($badJson | ForEach-Object { " $_" }) +
@("Delete the cache folder ($CachePath) and run Media Creator Lite again."))
}
# --- Parse user_selections.json ---
@@ -61,49 +197,60 @@ $SelectionsFile = Join-Path $ToolsPath "user_selections.json"
if (-not (Test-Path $SelectionsFile)) {
Write-Host "ERROR: user_selections.json not found at $SelectionsFile" -ForegroundColor Red
Write-Host " Run Media Creator Lite first to create a configuration." -ForegroundColor Yellow
exit 1
exit 2
}
$selections = (Get-Content $SelectionsFile -Raw | ConvertFrom-Json)[0]
$selections = (Read-JsonFile $SelectionsFile)[0]
$selectedOsId = $selections.OperatingSystemSelection
$selectedModelIds = @($selections.HardwareModelSelection | ForEach-Object { $_.Id } | Select-Object -Unique)
# --- Parse control JSONs ---
$ControlPath = Join-Path $DeployPath "Control"
$osJsonFile = Join-Path $ControlPath "OperatingSystem.json"
$driverJsonFile = Join-Path $ControlPath "HardwareDriver.json"
$pkgJsonFile = Join-Path $ControlPath "packages.json"
$langJsonFile = Join-Path $ControlPath "LanguagePacks.json"
if (-not (Test-Path $osJsonFile)) {
Write-Host "ERROR: OperatingSystem.json not found in $ControlPath" -ForegroundColor Red
exit 1
exit 2
}
$osJson = Get-Content $osJsonFile -Raw | ConvertFrom-Json
$driverJson = if (Test-Path $driverJsonFile) { Get-Content $driverJsonFile -Raw | ConvertFrom-Json } else { @() }
$pkgJson = if (Test-Path $pkgJsonFile) { Get-Content $pkgJsonFile -Raw | ConvertFrom-Json } else { @() }
$osJson = @(Read-JsonFile $osJsonFile)
$driverJson = if (Test-Path $driverJsonFile) { @(Read-JsonFile $driverJsonFile) } else { @() }
$pkgJson = if (Test-Path $pkgJsonFile) { @(Read-JsonFile $pkgJsonFile) } else { @() }
$langJson = if (Test-Path $langJsonFile) { @(Read-JsonFile $langJsonFile) } else { @() }
# --- The cached OS must be the selected one, and the one the tag names ---
$matchedOs = @($osJson | Where-Object { $_.operatingSystemVersion.id -eq [int]$selectedOsId })
if ($matchedOs.Count -eq 0) {
Stop-Cache @("OperatingSystem.json does not hold the OS selected in user_selections.json (ID $selectedOsId).",
"Run Media Creator Lite again.")
}
$osv = $matchedOs[0].operatingSystemVersion
if (-not $tagOs.StartsWith($osv.productName, [StringComparison]::OrdinalIgnoreCase)) {
Stop-Cache @("The MCL tag says '$tagOs' but OperatingSystem.json holds '$($osv.productName)'.",
"The cache is a mix of two runs. Run Media Creator Lite again.")
}
$isLtsc = $osv.productName -match 'LTSC'
$suggested = if ($isLtsc) { "gea-shopfloor" } else { "standard-or-engineer" }
# --- Resolve selections to paths ---
# OS: match OperatingSystemSelection ID to OperatingSystem.json entries
$matchedOs = @($osJson | Where-Object { $_.operatingSystemVersion.id -eq [int]$selectedOsId })
$osDirs = @()
$osTotalSize = [long]0
foreach ($os in $matchedOs) {
$rel = Resolve-DestDir $os.operatingSystemVersion.wim.DestinationDir
$osDirs += $rel
$osDirs += Resolve-DestDir $os.operatingSystemVersion.wim.DestinationDir
$osTotalSize += $os.operatingSystemVersion.wim.size
}
# Packages: enabled + matching OS ID
$matchedPkgs = @($pkgJson | Where-Object { $_.aOsIds -contains $selectedOsId -and $_.enabled -eq 1 })
# Packages: enabled + matching OS ID (osId is a comma string in the new schema)
function Get-OsIds { param($e) if ($e.aOsIds) { return @($e.aOsIds) } return @("$($e.osId)" -split ',' | ForEach-Object { $_.Trim() }) }
$matchedPkgs = @($pkgJson | Where-Object { (Get-OsIds $_) -contains "$selectedOsId" -and $_.enabled -eq 1 })
$pkgTotalSize = [long]0
foreach ($pkg in $matchedPkgs) { $pkgTotalSize += $pkg.size }
# Drivers: match selected model IDs (family) + OS ID, deduplicate by path
$allMatchingDrivers = @($driverJson | Where-Object {
$selectedModelIds -contains $_.family -and $_.aOsIds -contains $selectedOsId
$selectedModelIds -contains $_.family -and (Get-OsIds $_) -contains "$selectedOsId"
})
$allDriverDirSet = [ordered]@{}
foreach ($drv in $allMatchingDrivers) {
@@ -123,122 +270,232 @@ if ($IncludeDrivers) {
# --- Display upload plan ---
Write-Host " Cache: $CachePath"
Write-Host " MCL run: $tagOs, finished $tagTime" -ForegroundColor Green
Write-Host " Server: $Server"
Write-Host " Target: $(if ($isLtsc) { 'Shop Floor (LTSC)' } else { 'Standard or Engineer (pick it in the webapp)' })"
Write-Host ""
Write-Host " Upload Plan (from user_selections.json):" -ForegroundColor Cyan
Write-Host " ------------------------------------------"
if ($matchedOs.Count -gt 0) {
$osName = $matchedOs[0].operatingSystemVersion.marketingName
Write-Host " OS: $osName ($(Format-Size $osTotalSize))" -ForegroundColor Green
} else {
Write-Host " OS: No match for selection ID $selectedOsId" -ForegroundColor Red
}
Write-Host " OS: $($osv.marketingName) $($osv.productName) ($(Format-Size $osTotalSize))" -ForegroundColor Green
Write-Host " Packages: $($matchedPkgs.Count) update(s) ($(Format-Size $pkgTotalSize))" -ForegroundColor Green
if ($IncludeDrivers) {
Write-Host " Drivers: $($driverDirs.Count) model(s) ($(Format-Size $driverTotalSize))" -ForegroundColor Green
} else {
Write-Host " Drivers: SKIPPED -- $allDriverCount available, use -IncludeDrivers" -ForegroundColor Yellow
}
Write-Host " Control: Always included" -ForegroundColor Gray
Write-Host " Tools: $(if (Test-Path $ToolsPath) { 'Yes' } else { 'Not found' })" -ForegroundColor $(if (Test-Path $ToolsPath) { "Gray" } else { "Yellow" })
Write-Host " Sources: $(if (Test-Path $SourcesZip) { 'Yes (from Boot\Sources.zip)' } else { 'Not found' })" -ForegroundColor $(if (Test-Path $SourcesZip) { "Gray" } else { "Yellow" })
Write-Host ""
# --- Connect to SMB share ---
Write-Host "Connecting to $Share ..." -ForegroundColor Gray
net use $Share /delete 2>$null | Out-Null
# --- Sources: extract now so it can be hashed with everything else ---
$TempExtract = $null
$TempSources = $null
if (Test-Path $SourcesZip) {
$TempExtract = Join-Path $env:TEMP "SourcesExtract"
Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue
Expand-Archive $SourcesZip -DestinationPath $TempExtract -Force
# Handle nested Sources folder (zip may contain Sources/ at root)
$TempSources = $TempExtract
if ((Test-Path (Join-Path $TempExtract "Sources")) -and -not (Test-Path (Join-Path $TempExtract "Diskpart"))) {
$TempSources = Join-Path $TempExtract "Sources"
}
}
$netResult = net use $Share /user:$User $Pass 2>&1
# --- Everything that will be sent: local file + path on the share ---
$sendList = New-Object System.Collections.Generic.List[object]
function Add-Tree {
param([string]$LocalDir, [string]$RelPrefix, [string[]]$SkipDirs = @())
if (-not (Test-Path -LiteralPath $LocalDir -PathType Container)) { return }
$base = (Get-Item -LiteralPath $LocalDir).FullName.TrimEnd('\')
foreach ($f in Get-ChildItem -LiteralPath $base -Recurse -File -Force) {
$rel = $f.FullName.Substring($base.Length + 1)
if ($SkipDirs.Count -gt 0) {
# robocopy /XD <name> skips that folder name at any depth
$segs = $rel.Split('\')
$skip = $false
for ($i = 0; $i -lt $segs.Count - 1; $i++) { if ($SkipDirs -contains $segs[$i]) { $skip = $true; break } }
if ($skip) { continue }
}
$sendList.Add([pscustomobject]@{ Local = $f.FullName; Rel = (Join-Path $RelPrefix $rel); Size = $f.Length })
}
}
Add-Tree $DeployPath "Deploy" $ExcludedDeployDirs
foreach ($d in $osDirs) { Add-Tree (Join-Path $CachePath $d) $d }
foreach ($pkg in $matchedPkgs) {
$pdir = Resolve-DestDir $pkg.destinationDir
$rel = if ($pdir) { Join-Path $pdir $pkg.fileName } else { $pkg.fileName }
$local = Join-Path $CachePath $rel
if (Test-Path -LiteralPath $local -PathType Leaf) {
# File name as robocopy will write it (disk case), not as the JSON spells it
$item = Get-Item -LiteralPath $local
$rel = Join-Path (Split-Path $rel -Parent) $item.Name
$sendList.Add([pscustomobject]@{ Local = $item.FullName; Rel = $rel; Size = $item.Length })
}
}
foreach ($d in $driverDirs) { Add-Tree (Join-Path $CachePath $d) $d }
Add-Tree $ToolsPath "Tools"
if ($TempSources) { Add-Tree $TempSources "Sources" }
# The same file can be reached twice (an OS folder outside the excluded
# Deploy dirs, nested driver folders, two packages naming one file); a
# duplicate path would make the server refuse the whole manifest.
$seenRel = @{}
$dedup = New-Object System.Collections.Generic.List[object]
foreach ($f in $sendList) {
$k = ($f.Rel -replace '\\', '/').ToLower()
if (-not $seenRel.ContainsKey($k)) { $seenRel[$k] = $true; $dedup.Add($f) }
}
$sendList = $dedup
# --- Hash everything; check payload against MCL's published hashes ---
Write-Step "Hashing $($sendList.Count) file(s) ($(Format-Size (($sendList | Measure-Object Size -Sum).Sum)))"
$mclHash = @{}
function Add-Mcl { param($Dest, $Name, $Hash)
# Only a real SHA-256 (as the server checks): anything else is not ours to judge
if (-not ($Dest -and $Name -and ("$Hash" -match '^[0-9a-fA-F]{64}$'))) { return }
$dd = Resolve-DestDir $Dest
$key = if ($dd) { Join-Path $dd $Name } else { "$Name" }
$mclHash[$key.ToLower()] = "$Hash".ToLower() }
foreach ($e in $osJson) {
$w = $e.operatingSystemVersion.wim
$n = if ($w.fileName) { $w.fileName } else { "install.wim" }
Add-Mcl $w.destinationDir $n $w.hash
}
foreach ($e in @($driverJson) + @($pkgJson)) { if ($e) { Add-Mcl $e.destinationDir $e.fileName $e.hash } }
foreach ($e in $langJson) { foreach ($lf in @($e.files)) { if ($lf) { Add-Mcl $lf.destFolder $lf.fileName $lf.hash } } }
$totalBytes = [long](($sendList | Measure-Object Size -Sum).Sum)
$doneBytes = [long]0
$badCache = @()
$i = 0
foreach ($f in $sendList) {
$i++
$pct = if ($totalBytes -gt 0) { [int](100 * $doneBytes / $totalBytes) } else { 100 }
Write-Progress -Activity "Hashing" -Status "$i/$($sendList.Count) $($f.Rel)" -PercentComplete $pct
$f | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-FileHash -LiteralPath $f.Local -Algorithm SHA256).Hash.ToLower()
$doneBytes += $f.Size
$want = $mclHash[$f.Rel.ToLower()]
if ($want -and $want -ne $f.Sha256) { $badCache += $f }
}
Write-Progress -Activity "Hashing" -Completed
if ($badCache.Count -gt 0) {
Write-Host ""
Write-Host "These cache files do not match the hash MCL publishes for them (damaged download):" -ForegroundColor Red
foreach ($b in $badCache) { Write-Host " $($b.Rel)" -ForegroundColor Red }
Write-Host ""
Write-Host "MCL re-downloads a MISSING file correctly, but trusts a damaged one of the right size." -ForegroundColor Yellow
$ans = Read-Host "Delete these $($badCache.Count) file(s) from the MCL cache so MCL downloads them again? (Y/N)"
if ($ans -match '^[Yy]') {
foreach ($b in $badCache) { Remove-Item -LiteralPath $b.Local -Force; Write-Host " deleted $($b.Local)" -ForegroundColor Gray }
Write-Host "Now run Media Creator Lite again, then this script." -ForegroundColor Yellow
}
if ($TempExtract) { Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue }
Write-Host "Nothing was uploaded." -ForegroundColor Red
exit 2
}
Write-Host " All $($sendList.Count) file(s) hashed; payload matches MCL's published hashes." -ForegroundColor Green
$pcName = ($env:COMPUTERNAME -replace '[^A-Za-z0-9._-]', '')
$uploadId = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss"), $pcName
if ($uploadId.Length -gt 80) { $uploadId = $uploadId.Substring(0, 80) }
$manifest = [ordered]@{
manifest_version = 1
upload_id = $uploadId
created = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz")
source_pc = $pcName
mcl = [ordered]@{
tag = $tag.Name
finished = $tagTime.ToString("yyyy-MM-ddTHH:mm:ss")
os_id = [int]$osv.id
os_name = "$($osv.productName)"
os_version = "$($osv.versionNumber)"
build = "$($osv.buildNumber)"
suggested_target = $suggested
}
files = @($sendList | ForEach-Object { [ordered]@{ path = ($_.Rel -replace '\\', '/'); size = [long]$_.Size; sha256 = $_.Sha256 } })
}
$manifestBytes = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 6 -Compress))
# --- Is the webapp reachable? (before spending time on the copy) ---
$ping = Invoke-Pxe -Method GET -Path "/api/upload/verify" -TimeoutSec 15
if ($ping.Status -ne 200) {
Write-Host "ERROR: the PXE webapp at $ApiBase does not answer ($($ping.Status) $($ping.Body))." -ForegroundColor Red
Write-Host " The upload cannot be verified without it. Check the server, then run this again." -ForegroundColor Yellow
if ($TempExtract) { Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue }
exit 1
}
# --- Connect to SMB share ---
Write-Host ""
Write-Host "Connecting to $Share ..." -ForegroundColor Gray
# net use goes through cmd: in Windows PowerShell 5.1 a native command's
# redirected stderr becomes an error record, which ErrorAction Stop turns
# into a crash ("The network connection could not be found").
cmd /c "net use ""$Share"" /delete /y >nul 2>&1" | Out-Null
$netResult = cmd /c "net use ""$Share"" /user:$User ""$Pass"" 2>&1"
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Could not connect to $Share" -ForegroundColor Red
Write-Host $netResult -ForegroundColor Red
Write-Host ""
Write-Host "Make sure:" -ForegroundColor Yellow
Write-Host " - The PXE server is running at $Server" -ForegroundColor Yellow
Write-Host " - This PC is on the 10.9.100.x network" -ForegroundColor Yellow
Write-Host " - This PC is on the 172.16.9.x imaging network" -ForegroundColor Yellow
Write-Host " - Samba is running on the PXE server" -ForegroundColor Yellow
if ($TempExtract) { Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue }
exit 1
}
Write-Host "Connected." -ForegroundColor Green
Write-Host ""
$failed = $false
$stepNum = 0
$totalSteps = 1 # Deploy base always
if ($matchedOs.Count -gt 0) { $totalSteps++ }
$totalSteps = 2 # Deploy base + OS always
if ($matchedPkgs.Count -gt 0) { $totalSteps++ }
if ($IncludeDrivers -and $driverDirs.Count -gt 0) { $totalSteps++ }
if (Test-Path $ToolsPath -PathType Container) { $totalSteps++ }
if (Test-Path $SourcesZip) { $totalSteps++ }
if ($TempSources) { $totalSteps++ }
# robocopy exit codes 8 and up are failures
$rcArgs = @("/R:3", "/W:5", "/NP", "/ETA")
# --- Step: Deploy base (Control, Applications, config -- skip big dirs) ---
$stepNum++
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "[$stepNum/$totalSteps] Copying Deploy\ base (Control, Applications, config) ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
robocopy $DeployPath "$Share\Deploy" /E /XD "Operating Systems" "Out-of-box Drivers" "Packages" /R:3 /W:5 /NP /ETA
if ($LASTEXITCODE -ge 8) {
Write-Host "ERROR: Deploy base copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red
$failed = $true
}
Write-Step "[$stepNum/$totalSteps] Copying Deploy\ base (Control, Applications, config) ..."
robocopy $DeployPath "$Share\Deploy" /E /XD @ExcludedDeployDirs @rcArgs
if ($LASTEXITCODE -ge 8) { Write-Host "ERROR: Deploy base copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red; $failed = $true }
# --- Step: Operating System ---
if ($matchedOs.Count -gt 0) {
$stepNum++
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "[$stepNum/$totalSteps] Copying Operating System ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
foreach ($osDir in $osDirs) {
$src = Join-Path $CachePath $osDir
$dst = Join-Path $Share $osDir
if (Test-Path $src -PathType Container) {
Write-Host " $osDir" -ForegroundColor Gray
robocopy $src $dst /E /R:3 /W:5 /NP /ETA
if ($LASTEXITCODE -ge 8) {
Write-Host "ERROR: OS copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red
$failed = $true
}
} else {
Write-Host " SKIPPED (not cached): $osDir" -ForegroundColor Yellow
}
$stepNum++
Write-Step "[$stepNum/$totalSteps] Copying Operating System ..."
foreach ($osDir in $osDirs) {
$src = Join-Path $CachePath $osDir
if (Test-Path -LiteralPath $src -PathType Container) {
Write-Host " $osDir" -ForegroundColor Gray
robocopy $src (Join-Path $Share $osDir) /E @rcArgs
if ($LASTEXITCODE -ge 8) { Write-Host "ERROR: OS copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red; $failed = $true }
} else {
Write-Host " SKIPPED (not cached): $osDir" -ForegroundColor Yellow
}
}
# --- Step: Packages ---
if ($matchedPkgs.Count -gt 0) {
$stepNum++
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "[$stepNum/$totalSteps] Copying Packages ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# Group packages by destination directory for efficient robocopy
Write-Step "[$stepNum/$totalSteps] Copying Packages ..."
$pkgGroups = [ordered]@{}
foreach ($pkg in $matchedPkgs) {
$rel = Resolve-DestDir $pkg.destinationDir
if (-not $pkgGroups.Contains($rel)) { $pkgGroups[$rel] = @() }
$pkgGroups[$rel] += $pkg.fileName
}
foreach ($dir in $pkgGroups.Keys) {
$src = Join-Path $CachePath $dir
$dst = Join-Path $Share $dir
$files = $pkgGroups[$dir]
if (Test-Path $src -PathType Container) {
foreach ($f in $files) { Write-Host " $f" -ForegroundColor Gray }
$robocopyArgs = @($src, $dst) + $files + @("/R:3", "/W:5", "/NP", "/ETA")
if (Test-Path -LiteralPath $src -PathType Container) {
foreach ($f in $pkgGroups[$dir]) { Write-Host " $f" -ForegroundColor Gray }
$robocopyArgs = @($src, (Join-Path $Share $dir)) + $pkgGroups[$dir] + $rcArgs
& robocopy @robocopyArgs
if ($LASTEXITCODE -ge 8) {
Write-Host "ERROR: Package copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red
$failed = $true
}
if ($LASTEXITCODE -ge 8) { Write-Host "ERROR: Package copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red; $failed = $true }
} else {
Write-Host " SKIPPED (not cached): $dir" -ForegroundColor Yellow
}
@@ -248,23 +505,15 @@ if ($matchedPkgs.Count -gt 0) {
# --- Step: Drivers (only with -IncludeDrivers) ---
if ($IncludeDrivers -and $driverDirs.Count -gt 0) {
$stepNum++
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "[$stepNum/$totalSteps] Copying Drivers ($($driverDirs.Count) model(s)) ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Step "[$stepNum/$totalSteps] Copying Drivers ($($driverDirs.Count) model(s)) ..."
$drvCopied = 0
foreach ($drvDir in $driverDirs) {
$drvCopied++
$src = Join-Path $CachePath $drvDir
$dst = Join-Path $Share $drvDir
if (Test-Path $src -PathType Container) {
if (Test-Path -LiteralPath $src -PathType Container) {
Write-Host " [$drvCopied/$($driverDirs.Count)] $drvDir" -ForegroundColor Gray
robocopy $src $dst /E /R:3 /W:5 /NP /ETA
if ($LASTEXITCODE -ge 8) {
Write-Host "ERROR: Driver copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red
$failed = $true
}
robocopy $src (Join-Path $Share $drvDir) /E @rcArgs
if ($LASTEXITCODE -ge 8) { Write-Host "ERROR: Driver copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red; $failed = $true }
} else {
Write-Host " SKIPPED (not cached): $drvDir" -ForegroundColor Yellow
}
@@ -274,106 +523,118 @@ if ($IncludeDrivers -and $driverDirs.Count -gt 0) {
# --- Step: Tools ---
if (Test-Path $ToolsPath -PathType Container) {
$stepNum++
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "[$stepNum/$totalSteps] Copying Tools\ ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
robocopy $ToolsPath "$Share\Tools" /E /R:3 /W:5 /NP /ETA
if ($LASTEXITCODE -ge 8) {
Write-Host "ERROR: Tools copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red
$failed = $true
}
Write-Step "[$stepNum/$totalSteps] Copying Tools\ ..."
robocopy $ToolsPath "$Share\Tools" /E @rcArgs
if ($LASTEXITCODE -ge 8) { Write-Host "ERROR: Tools copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red; $failed = $true }
}
# --- Step: Sources ---
$TempSources = $null
if (Test-Path $SourcesZip) {
if ($TempSources) {
$stepNum++
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "[$stepNum/$totalSteps] Extracting and copying Sources\ ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
$TempExtract = Join-Path $env:TEMP "SourcesExtract"
Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue
Expand-Archive $SourcesZip -DestinationPath $TempExtract -Force
# Handle nested Sources folder (zip may contain Sources/ at root)
$TempSources = $TempExtract
if ((Test-Path (Join-Path $TempExtract "Sources")) -and -not (Test-Path (Join-Path $TempExtract "Diskpart"))) {
$TempSources = Join-Path $TempExtract "Sources"
}
robocopy $TempSources "$Share\Sources" /E /R:3 /W:5 /NP /ETA
if ($LASTEXITCODE -ge 8) {
Write-Host "ERROR: Sources copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red
$failed = $true
}
Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue
Write-Step "[$stepNum/$totalSteps] Copying Sources\ ..."
robocopy $TempSources "$Share\Sources" /E @rcArgs
if ($LASTEXITCODE -ge 8) { Write-Host "ERROR: Sources copy failed (exit code $LASTEXITCODE)" -ForegroundColor Red; $failed = $true }
}
# --- Verify small files (SMB write-cache workaround) ---
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Verifying small files ..." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# --- Verify on the server; re-send what is wrong ---
$byRel = @{}
foreach ($f in $sendList) { $byRel[($f.Rel -replace '\\', '/').ToLower()] = $f }
$verified = $false
$result = $null
for ($round = 1; $round -le $MaxRounds + 1; $round++) {
Write-Step "Verifying on the server (round $round) ..."
$r = Invoke-Pxe -Method POST -Path "/api/upload/verify" -Body $manifestBytes -TimeoutSec 120
if ($r.Status -ne 202) {
Write-Host "ERROR: verify request refused ($($r.Status)): $(Get-ErrorText $r)" -ForegroundColor Red
break
}
$deadline = (Get-Date).AddHours(2)
$postedAt = Get-Date
do {
if ((Get-Date) -gt $deadline) { $result = $null; break }
if ($result -and $result.upload_id -ne $uploadId -and ((Get-Date) - $postedAt).TotalSeconds -gt 120) {
Write-Host "ERROR: the server is working on another upload ($($result.upload_id)). Run this again later." -ForegroundColor Red
$result = $null; break
}
Start-Sleep -Seconds 2
$s = Invoke-Pxe -Method GET -Path "/api/upload/verify" -TimeoutSec 30
$result = $null
if ($s.Status -eq 200) { $result = $s.Body | ConvertFrom-Json }
if ($result -and $result.state -eq "running" -and $result.total_bytes -gt 0) {
Write-Progress -Activity "Server is hashing the upload" -Status "$($result.done_files)/$($result.total_files) files" `
-PercentComplete ([Math]::Min(100, [int](100 * $result.done_bytes / $result.total_bytes)))
}
} while (-not $result -or $result.state -eq "running" -or $result.upload_id -ne $uploadId)
Write-Progress -Activity "Server is hashing the upload" -Completed
$fixCount = 0
$verifyPairs = @(
@{ Local = (Join-Path $DeployPath "Control"); Remote = "$Share\Deploy\Control" }
)
if (Test-Path $ToolsPath -PathType Container) {
$verifyPairs += @{ Local = $ToolsPath; Remote = "$Share\Tools" }
}
if (-not $result) { Write-Host "ERROR: the server did not finish verifying within 2 hours." -ForegroundColor Red; break }
if ($result.state -eq "error") { Write-Host "ERROR: the server could not verify: $($result.error)" -ForegroundColor Red; break }
if ($result.ok) { $verified = $true; break }
foreach ($pair in $verifyPairs) {
$localDir = $pair.Local
$remoteDir = $pair.Remote
if (-not (Test-Path $localDir -PathType Container)) { continue }
foreach ($b in @($result.bad_in_cache)) { if ($b) { Write-Host " BAD IN MCL CACHE: $($b.path) ($($b.reason))" -ForegroundColor Red } }
if (@($result.bad_in_cache).Count -gt 0) { break } # re-sending a bad source cannot help
Get-ChildItem -Path $localDir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Length -gt 0 -and $_.Length -lt 1MB } |
ForEach-Object {
$rel = $_.FullName.Substring($localDir.Length)
$dstFile = Join-Path $remoteDir $rel
if (Test-Path $dstFile) {
$dstSize = (Get-Item $dstFile).Length
if ($dstSize -ne $_.Length) {
Write-Host " Fixing: $rel ($dstSize -> $($_.Length) bytes)" -ForegroundColor Yellow
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
[System.IO.File]::WriteAllBytes($dstFile, $bytes)
$fixCount++
}
$redo = @(@($result.missing) + @($result.mismatch | ForEach-Object { $_.path }) | Where-Object { $_ })
Write-Host " $($redo.Count) file(s) missing or different on the server." -ForegroundColor Yellow
if ($round -gt $MaxRounds) { break }
foreach ($rel in $redo) {
$f = $byRel[$rel.ToLower()]
if (-not $f) { continue }
if ($f.Size -le [long]$result.small_max) {
$q = "?upload_id=$([uri]::EscapeDataString($uploadId))&path=$([uri]::EscapeDataString($rel))"
$p = Invoke-Pxe -Method PUT -Path "/api/upload/file$q" -InFile $f.Local -ContentType "application/octet-stream" -TimeoutSec 300
if ($p.Status -eq 200) { Write-Host " re-sent over HTTP: $rel" -ForegroundColor Green }
else { Write-Host " HTTP re-send failed ($($p.Status)) for ${rel}: $(Get-ErrorText $p)" -ForegroundColor Red }
} else {
$dstDir = Join-Path $Share (Split-Path ($f.Rel) -Parent)
$dstFile = Join-Path $dstDir (Split-Path $f.Local -Leaf)
Write-Host " re-copying (delete, then robocopy /J): $rel" -ForegroundColor Gray
# robocopy calls a same-size, same-time file "same" or "modified"
# and skips it, even with /IS /IT. Delete the bad copy so it is a
# new file; /IS /IT /IM stay as a backstop if the delete fails.
Remove-Item -LiteralPath $dstFile -Force -ErrorAction SilentlyContinue
$rc = robocopy (Split-Path $f.Local -Parent) $dstDir (Split-Path $f.Local -Leaf) /J /IS /IT /IM @rcArgs
if ($LASTEXITCODE -ge 8 -or $LASTEXITCODE -eq 0) {
# 0 = nothing copied: robocopy skipped it again
Write-Host " robocopy did not copy $rel (exit $LASTEXITCODE):" -ForegroundColor Red
$rc | Where-Object { $_ -match '\S' } | Select-Object -Last 8 | ForEach-Object { Write-Host " $_" }
}
}
}
}
if ($fixCount -eq 0) {
Write-Host " All files verified OK." -ForegroundColor Green
} else {
Write-Host " Fixed $fixCount file(s)." -ForegroundColor Yellow
}
# --- Disconnect ---
net use $Share /delete 2>$null | Out-Null
# --- Disconnect and clean up ---
cmd /c "net use ""$Share"" /delete /y >nul 2>&1" | Out-Null
if ($TempExtract) { Remove-Item -Recurse -Force $TempExtract -ErrorAction SilentlyContinue }
# --- Summary ---
Write-Host ""
if ($failed) {
Write-Host "========================================" -ForegroundColor Red
Write-Host " Upload completed with errors." -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
} else {
Write-Host "========================================" -ForegroundColor Green
Write-Host " Upload complete!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
if ($failed -and $verified) {
Write-Host " (robocopy reported errors on the first pass; the files it missed were re-sent and verified.)" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host " 1. Open the PXE webapp: http://$Server`:9009" -ForegroundColor White
Write-Host " 2. Go to Image Import" -ForegroundColor White
Write-Host " 3. Select source 'image-upload' and target image type" -ForegroundColor White
Write-Host " 4. Click Import" -ForegroundColor White
Write-Host ""
if ($verified) {
Write-Host "========================================" -ForegroundColor Green
Write-Host " Upload complete: all $($sendList.Count) file(s) verified on the server." -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
if ([int]$result.extra_count -gt 0) {
Write-Host ""
Write-Host " NOTE: the upload folder also holds $($result.extra_count) file(s) from an earlier upload." -ForegroundColor Yellow
Write-Host " Import is blocked until you remove them (button on the Image Import page)." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host " 1. Open the PXE webapp: http://${Server}:$WebPort/images/import" -ForegroundColor White
Write-Host " 2. Target: $(if ($isLtsc) { 'Shop Floor (the only choice for LTSC)' } else { 'Standard or Engineer' })" -ForegroundColor White
Write-Host " 3. Click Start import" -ForegroundColor White
Write-Host ""
exit 0
}
Write-Host "========================================" -ForegroundColor Red
Write-Host " Upload NOT verified." -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
if ($result) {
foreach ($m in @($result.missing)) { if ($m) { Write-Host " missing: $m" -ForegroundColor Red } }
foreach ($m in @($result.mismatch)) { if ($m) { Write-Host " differs: $($m.path) ($($m.reason))" -ForegroundColor Red } }
}
Write-Host "The webapp will not import this upload. Run the script again." -ForegroundColor Yellow
exit 1