Files
pxe-server/playbook/shopfloor-setup/Standard/lib/Install-FromManifest.ps1
cproudlock 8528a1bcae Install-FromManifest: add FileVersion detection for version-pinned upgrades
File-existence detection on NTLARS.exe couldn't tell eDNC 6.4.3 from 6.4.4
(both installers leave the same binary in place), so the enforcer skipped
upgrades. FileVersion compares the vendor-stamped FileVersion field on a
named binary against the manifest's DetectionValue with exact-string match.

Added to all three lib copies (common, Standard, CMM). Standard manifest
template flipped to FileVersion against DncMain.exe -- the eDNC main
binary is more reliably version-stamped than the bundled NTLARS sub-tool.

Update workflow now: drop the new vendor MSI on the SFLD share, bump
Installer + DetectionValue in machineapps-manifest.json, next user logon
runs Machine-Enforce which detects mismatch and installs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:15:32 -04:00

290 lines
11 KiB
PowerShell

# Install-FromManifest.ps1 - Generic JSON-manifest installer for cross-PC-type
# apps enforced from the SFLD share (Acrobat Reader DC today; others later).
#
# Duplicated from CMM\lib\Install-FromManifest.ps1 with a few differences:
# - adds Type=CMD (cmd.exe /c wrapper, needed for Acrobat's two-step
# MSI + MSP install that the vendor ships as Install-AcroReader.cmd)
# - unchanged otherwise; a future pass will unify both libraries.
#
# Called from:
# - Acrobat-Enforce.ps1 on logon with InstallerRoot=<mounted tsgwp00525 share>
#
# Returns via exit code: 0 if every required app is either already installed
# or installed successfully; non-zero if any install failed.
param(
[Parameter(Mandatory=$true)]
[string]$ManifestPath,
[Parameter(Mandatory=$true)]
[string]$InstallerRoot,
[Parameter(Mandatory=$true)]
[string]$LogFile
)
$ErrorActionPreference = 'Continue'
$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-InstallLog {
param(
[Parameter(Mandatory=$true, Position=0)]
[string]$Message,
[Parameter(Position=1)]
[ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO'
)
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$stamp] [$Level] $Message"
Write-Host $line
try {
$fs = New-Object System.IO.FileStream(
$LogFile,
[System.IO.FileMode]::Append,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::Read,
4096,
[System.IO.FileOptions]::WriteThrough
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n")
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
$fs.Dispose()
} catch {
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
}
}
Write-InstallLog "================================================================"
Write-InstallLog "=== Install-FromManifest session start (PID $PID) ==="
Write-InstallLog "Manifest: $ManifestPath"
Write-InstallLog "InstallerRoot: $InstallerRoot"
Write-InstallLog "================================================================"
if (-not (Test-Path -LiteralPath $ManifestPath)) {
Write-InstallLog "Manifest not found: $ManifestPath" "ERROR"
exit 2
}
if (-not (Test-Path -LiteralPath $InstallerRoot)) {
Write-InstallLog "InstallerRoot not found: $InstallerRoot" "ERROR"
exit 2
}
try {
$config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
} catch {
Write-InstallLog "Failed to parse manifest: $_" "ERROR"
exit 2
}
if (-not $config.Applications) {
Write-InstallLog "No Applications in manifest - nothing to do"
exit 0
}
Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)"
function Test-AppInstalled {
param($App)
if (-not $App.DetectionMethod) { return $false }
try {
switch ($App.DetectionMethod) {
"Registry" {
if (-not (Test-Path $App.DetectionPath)) { return $false }
if ($App.DetectionName) {
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
if (-not $value) { return $false }
if ($App.DetectionValue) {
return ($value.$($App.DetectionName) -eq $App.DetectionValue)
}
return $true
}
return $true
}
"File" {
return Test-Path $App.DetectionPath
}
"FileVersion" {
# Compare a file's VersionInfo.FileVersion against the
# manifest's expected value. Used for version-pinned MSI/EXE
# installs where existence alone doesn't tell you whether
# the right release is on disk (e.g. eDNC 6.4.3 vs 6.4.4
# both leave NTLARS.exe in the same path). Exact string
# match - the manifest must carry the exact version the
# vendor stamps into the binary.
if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionValue) {
Write-InstallLog " FileVersion detection requires DetectionValue - treating as not installed" "WARN"
return $false
}
$actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion
if (-not $actual) { return $false }
return ($actual -eq $App.DetectionValue)
}
"Hash" {
# Compare SHA256 of the on-disk file against the manifest's
# expected value. Used for content-versioned files that do not
# expose a DisplayVersion (secrets like eMxInfo.txt). Bumping
# DetectionValue in the manifest and replacing the file on the
# share is the entire update workflow.
if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionValue) {
Write-InstallLog " Hash detection requires DetectionValue - treating as not installed" "WARN"
return $false
}
$actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash
return ($actual -ieq $App.DetectionValue)
}
default {
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
return $false
}
}
} catch {
Write-InstallLog " Detection check threw: $_" "WARN"
return $false
}
}
$installed = 0
$skipped = 0
$failed = 0
foreach ($app in $config.Applications) {
cmd /c "shutdown /a 2>nul" *>$null
Write-InstallLog "==> $($app.Name)"
if (Test-AppInstalled -App $app) {
Write-InstallLog " Already installed at expected version - skipping"
$skipped++
continue
}
$installerPath = Join-Path $InstallerRoot $app.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " Installer file not found: $installerPath" "ERROR"
$failed++
continue
}
Write-InstallLog " Installing from $installerPath"
if ($app.InstallArgs) {
Write-InstallLog " InstallArgs: $($app.InstallArgs)"
}
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
$msiLog = $null
if ($app.Type -eq "MSI") {
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
$msiLog = Join-Path $logDir "msi-$safeName.log"
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
$psi.FileName = "msiexec.exe"
$psi.Arguments = "/i `"$installerPath`""
if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs }
$psi.Arguments += " /L*v `"$msiLog`""
Write-InstallLog " msiexec verbose log: $msiLog"
}
elseif ($app.Type -eq "EXE") {
$psi.FileName = $installerPath
if ($app.InstallArgs) { $psi.Arguments = $app.InstallArgs }
if ($app.LogFile) {
Write-InstallLog " Installer log: $($app.LogFile)"
}
}
elseif ($app.Type -eq "CMD") {
# .cmd/.bat scripts cannot be executed directly via
# ProcessStartInfo with UseShellExecute=false; route through
# cmd.exe /c. Vendor-provided two-step install wrappers
# (Install-AcroReader.cmd) fit here naturally.
$psi.FileName = "cmd.exe"
$psi.Arguments = "/c `"$installerPath`""
if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs }
if ($app.LogFile) {
Write-InstallLog " Installer log: $($app.LogFile)"
}
}
else {
Write-InstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
$failed++
continue
}
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$exitCode = $proc.ExitCode
if ($exitCode -eq 0 -or $exitCode -eq 1641 -or $exitCode -eq 3010) {
Write-InstallLog " Exit code $exitCode - SUCCESS"
if ($exitCode -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" }
if ($exitCode -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
$installed++
}
else {
Write-InstallLog " Exit code $exitCode - FAILED" "ERROR"
if (($app.Type -eq "EXE" -or $app.Type -eq "CMD") -and $app.LogFile -and (Test-Path $app.LogFile)) {
Write-InstallLog " --- last 30 lines of $($app.LogFile) ---"
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
Write-InstallLog " $_"
}
Write-InstallLog " --- end installer log tail ---"
}
if ($app.Type -eq "MSI" -and $msiLog -and (Test-Path $msiLog)) {
Write-InstallLog " --- meaningful lines from $msiLog ---"
$patterns = @(
'Note: 1: ',
'return value 3',
'Error \d+\.',
'CustomAction .* returned actual error',
'Failed to ',
'Installation failed',
'1: 2262',
'1: 2203',
'1: 2330'
)
$regex = ($patterns -join '|')
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
Select-Object -First 30
if ($matches) {
foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" }
} else {
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
Write-InstallLog " $_"
}
}
Write-InstallLog " --- end MSI log scan ---"
}
$failed++
}
} catch {
Write-InstallLog " Install threw: $_" "ERROR"
$failed++
}
}
Write-InstallLog "============================================"
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed"
Write-InstallLog "============================================"
cmd /c "shutdown /a 2>nul" *>$null
if ($failed -gt 0) { exit 1 }
exit 0