Retire v1 per-pctype enforcers; GE-Enforce is the sole dispatcher
Stage 2a (GE-Enforce.ps1, landed 2026-04-22) is now the only ongoing-update
enforcer. The legacy per-pctype tasks (Machine-Enforce, Common-Enforce,
CMM-Enforce, Keyence-Enforce, Acrobat-Enforce) were kept as transition
belt-and-suspenders; with retrofitted PCs handled, the v1 path is dead and
gets removed entirely.
Deleted (13 files):
Standard/{Machine-Enforce,Register-MachineEnforce}.ps1
Standard/machineapps-manifest.template.json
common/{Common-Enforce,Acrobat-Enforce,Register-CommonEnforce,Register-AcrobatEnforce}.ps1
common/common-apps-manifest.template.json
CMM/CMM-Enforce.ps1
Keyence/Keyence-Enforce.ps1
{CMM,Keyence,Standard}/lib/Install-FromManifest.ps1 (orphan dups of common/lib)
Trimmed:
Run-ShopfloorSetup.ps1: dropped the legacy register-* invocations (Common,
Machine) and the transition-period comment. Sole enforcer registration
is now Register-GEEnforce.
09-Setup-Keyence.ps1: keeps imaging-time install (step 1); removes the
enforcer staging (step 2) and scheduled-task registration (step 3).
Library lookup repointed to common/lib/Install-FromManifest.ps1.
09-Setup-CMM.ps1: same treatment - keeps .NET 3.5 enable, install,
PC-DMIS ACL grants, and bootstrap cleanup. Library repointed to common/lib.
cmm-manifest.json + keyence-manifest.json: _comment fields updated to
reflect imaging-time-only role (ongoing enforcement now goes through
the v2 share manifests via GE-Enforce).
Verified clean: no orphan references to *-Enforce.ps1 / Register-*Enforce.ps1
/ machineapps-manifest / common-apps-manifest in any code path that runs.
A few historical mentions remain in unmodified header comments (GE-Enforce.ps1,
Deploy-GEEnforce.ps1, Monitor-IntuneProgress.ps1) describing what the new
dispatcher replaced; left as historical context.
Run-ShopfloorSetup.ps1 also picks up an unrelated 1-line hunk adding
SetShopfloorAutoLogon.bat to the desktop-copy list (already in the working
tree from a prior session). The file itself is not yet tracked; the
desktop-copy step is Test-Path-guarded so this is harmless until the
.bat is committed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,19 +3,20 @@
|
||||
# At imaging time the tsgwp00525 SFLD share is NOT yet reachable - Azure DSC
|
||||
# has not provisioned the share credentials that early. So we install from a
|
||||
# WinPE-staged local copy at C:\CMM-Install (put there by startnet.cmd when
|
||||
# the tech picks pc-type=CMM), then register a logon-triggered scheduled
|
||||
# task that runs CMM-Enforce.ps1 for ongoing updates from the share.
|
||||
# the tech picks pc-type=CMM). Ongoing enforcement is handled by GE-Enforce
|
||||
# (registered separately in Run-ShopfloorSetup.ps1) reading cmm/manifest.json
|
||||
# from the tsgwp00525 share.
|
||||
#
|
||||
# Sequence:
|
||||
# 1. Enable .NET Framework 3.5 (PC-DMIS 2016 prereq on Win10/11 where 3.5
|
||||
# is an off-by-default optional feature).
|
||||
# 2. Run Install-FromManifest against C:\CMM-Install\cmm-manifest.json.
|
||||
# 3. Stage Install-FromManifest.ps1 + CMM-Enforce.ps1 + the manifest to
|
||||
# C:\Program Files\GE\CMM so the scheduled task has them after imaging.
|
||||
# 4. Register a SYSTEM scheduled task "GE CMM Enforce" that runs
|
||||
# CMM-Enforce.ps1 on any user logon.
|
||||
# 5. Delete C:\CMM-Install to reclaim the ~2 GB of bootstrap installers.
|
||||
# The share-side enforcer takes over from here.
|
||||
# 2.5. Grant BUILTIN\Users Modify on PC-DMIS install dirs (Hexagon-documented
|
||||
# approach for non-admin runtime).
|
||||
# 3. Delete C:\CMM-Install to reclaim the ~2 GB of bootstrap installers.
|
||||
#
|
||||
# Library lookup: the imaging-time install uses the common Install-FromManifest
|
||||
# library at ..\common\lib\Install-FromManifest.ps1 (relative to $PSScriptRoot).
|
||||
#
|
||||
# Log: C:\Logs\CMM\09-Setup-CMM.log (stdout from this script) plus the
|
||||
# install-time log at C:\Logs\CMM\install.log written by Install-FromManifest.
|
||||
@@ -24,13 +25,7 @@ $ErrorActionPreference = 'Continue'
|
||||
|
||||
$stagingRoot = 'C:\CMM-Install'
|
||||
$stagingMani = Join-Path $stagingRoot 'cmm-manifest.json'
|
||||
$libSource = Join-Path $PSScriptRoot 'lib\Install-FromManifest.ps1'
|
||||
$enforceSource = Join-Path $PSScriptRoot 'CMM-Enforce.ps1'
|
||||
|
||||
$runtimeRoot = 'C:\Program Files\GE\CMM'
|
||||
$runtimeLibDir = Join-Path $runtimeRoot 'lib'
|
||||
$runtimeLib = Join-Path $runtimeLibDir 'Install-FromManifest.ps1'
|
||||
$runtimeEnforce = Join-Path $runtimeRoot 'CMM-Enforce.ps1'
|
||||
$libSource = Join-Path $PSScriptRoot '..\common\lib\Install-FromManifest.ps1'
|
||||
|
||||
$logDir = 'C:\Logs\CMM'
|
||||
$logFile = Join-Path $logDir 'install.log'
|
||||
@@ -163,65 +158,10 @@ foreach ($dir in $pcdmisDirs) {
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 3: Stage runtime scripts to C:\Program Files\GE\CMM
|
||||
# Step 3: Clean up the bootstrap staging dir
|
||||
# ============================================================================
|
||||
# These files survive past the bootstrap cleanup so the logon-triggered
|
||||
# scheduled task can run them. The manifest is staged as well so the enforcer
|
||||
# has a fallback in case the share copy is unreachable on first logon.
|
||||
Write-CMMLog "Staging runtime scripts to $runtimeRoot"
|
||||
foreach ($dir in @($runtimeRoot, $runtimeLibDir)) {
|
||||
if (-not (Test-Path $dir)) {
|
||||
New-Item -Path $dir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
}
|
||||
Copy-Item -Path $libSource -Destination $runtimeLib -Force
|
||||
Copy-Item -Path $enforceSource -Destination $runtimeEnforce -Force
|
||||
|
||||
# ============================================================================
|
||||
# Step 4: Register "GE CMM Enforce" scheduled task (logon trigger, SYSTEM)
|
||||
# ============================================================================
|
||||
$taskName = 'GE CMM Enforce'
|
||||
|
||||
# Drop any stale version first so re-imaging is idempotent.
|
||||
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-CMMLog "Removing existing scheduled task '$taskName'"
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-CMMLog "Registering scheduled task '$taskName' (logon trigger, SYSTEM)"
|
||||
try {
|
||||
$action = New-ScheduledTaskAction `
|
||||
-Execute 'powershell.exe' `
|
||||
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$runtimeEnforce`""
|
||||
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $taskName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Principal $principal `
|
||||
-Settings $settings `
|
||||
-Description 'GE CMM: enforce Hexagon apps against tsgwp00525 SFLD share on user logon' | Out-Null
|
||||
|
||||
Write-CMMLog "Scheduled task registered"
|
||||
} catch {
|
||||
Write-CMMLog "Failed to register scheduled task: $_" "ERROR"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 5: Clean up the bootstrap staging dir
|
||||
# ============================================================================
|
||||
# ~2 GB reclaimed. From here on, CMM-Enforce.ps1 runs against the tsgwp00525
|
||||
# share, which is the canonical source for ongoing updates.
|
||||
# ~2 GB reclaimed. From here on, GE-Enforce takes over from the tsgwp00525
|
||||
# share for ongoing updates.
|
||||
if (Test-Path $stagingRoot) {
|
||||
Write-CMMLog "Deleting bootstrap staging at $stagingRoot"
|
||||
try {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# CMM-Enforce.ps1 - On-logon CMM app enforcer (the mini-DSC side).
|
||||
#
|
||||
# Runs under a SYSTEM scheduled task triggered at user logon. Mounts the
|
||||
# tsgwp00525 SFLD share using creds written to HKLM by Azure DSC, reads
|
||||
# cmm-manifest.json from the share, and hands off to Install-FromManifest.ps1
|
||||
# which installs anything whose detection fails.
|
||||
#
|
||||
# Why logon trigger: shopfloor operators log in at shift start; the PC may
|
||||
# have been off or DSC may not have provisioned the SFLD creds until the
|
||||
# Intune side ran post-PPKG. Logon is a natural catch-up point. Once detection
|
||||
# passes for every app, each run is ~seconds of no-ops.
|
||||
#
|
||||
# Why SYSTEM: installers need machine-wide rights and registry access. The
|
||||
# task is triggered by logon but runs outside the user's session.
|
||||
#
|
||||
# Graceful degradation:
|
||||
# - SFLD creds missing (Azure DSC hasn't run yet) -> log + exit 0
|
||||
# - Share unreachable (network, VPN) -> log + exit 0
|
||||
# - Install failure on any one app -> log + continue with the rest
|
||||
#
|
||||
# Never returns non-zero to the task scheduler; failures show up in the log.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$installRoot = 'C:\Program Files\GE\CMM'
|
||||
$libPath = Join-Path $installRoot 'lib\Install-FromManifest.ps1'
|
||||
$logDir = 'C:\Logs\CMM'
|
||||
$logFile = Join-Path $logDir ('enforce-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||
$driveLetter = 'S:'
|
||||
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
function Write-EnforceLog {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-EnforceLog "================================================================"
|
||||
Write-EnforceLog "=== CMM-Enforce session start (PID $PID, user $env:USERNAME) ==="
|
||||
Write-EnforceLog "================================================================"
|
||||
|
||||
# --- Load site-config + pcProfile (for cmmSharePath) ---
|
||||
# Dot-source the same Get-PCProfile.ps1 used during imaging. It walks
|
||||
# C:\Enrollment\site-config.json into $pcProfile/$siteConfig script variables.
|
||||
$getProfileScript = 'C:\Enrollment\shopfloor-setup\Shopfloor\lib\Get-PCProfile.ps1'
|
||||
if (-not (Test-Path $getProfileScript)) {
|
||||
Write-EnforceLog "Get-PCProfile.ps1 not found at $getProfileScript - is this a CMM PC?" "ERROR"
|
||||
exit 0
|
||||
}
|
||||
. $getProfileScript
|
||||
|
||||
if (-not $pcProfile -or -not $pcProfile.cmmSharePath) {
|
||||
Write-EnforceLog "No cmmSharePath in profile - nothing to enforce" "WARN"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$sharePath = $pcProfile.cmmSharePath
|
||||
Write-EnforceLog "Share: $sharePath"
|
||||
|
||||
# --- SFLD credential lookup (written by Azure DSC post-PPKG). Bail gracefully
|
||||
# if the creds haven't been provisioned yet - next logon will retry. ---
|
||||
function Get-SFLDCredential {
|
||||
param([string]$ServerName)
|
||||
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
|
||||
if (-not (Test-Path $basePath)) { return $null }
|
||||
|
||||
foreach ($entry in Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue) {
|
||||
$props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
|
||||
if (-not $props -or -not $props.TargetHost) { continue }
|
||||
if ($props.TargetHost -eq $ServerName -or
|
||||
$props.TargetHost -like "$ServerName.*" -or
|
||||
$ServerName -like "$($props.TargetHost).*") {
|
||||
return @{
|
||||
Username = $props.Username
|
||||
Password = $props.Password
|
||||
TargetHost = $props.TargetHost
|
||||
KeyName = $entry.PSChildName
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$serverName = ($sharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1
|
||||
$cred = Get-SFLDCredential -ServerName $serverName
|
||||
|
||||
if (-not $cred -or -not $cred.Username -or -not $cred.Password) {
|
||||
Write-EnforceLog "No SFLD credential for $serverName yet (Azure DSC has not provisioned it) - will retry at next logon"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-EnforceLog "Credential: $($cred.KeyName) (user: $($cred.Username))"
|
||||
|
||||
# --- Mount the share ---
|
||||
net use $driveLetter /delete /y 2>$null | Out-Null
|
||||
|
||||
$netResult = & net use $driveLetter $sharePath /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-EnforceLog "net use failed (exit $LASTEXITCODE): $netResult" "WARN"
|
||||
Write-EnforceLog "Share unreachable - probably off-network. Will retry at next logon."
|
||||
exit 0
|
||||
}
|
||||
Write-EnforceLog "Mounted $sharePath as $driveLetter"
|
||||
|
||||
try {
|
||||
$manifestOnShare = Join-Path $driveLetter 'cmm-manifest.json'
|
||||
if (-not (Test-Path $manifestOnShare)) {
|
||||
Write-EnforceLog "cmm-manifest.json not found on share - nothing to enforce" "WARN"
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-Path $libPath)) {
|
||||
Write-EnforceLog "Install-FromManifest.ps1 not found at $libPath" "ERROR"
|
||||
return
|
||||
}
|
||||
|
||||
Write-EnforceLog "Handing off to Install-FromManifest.ps1 (InstallerRoot=$driveLetter)"
|
||||
& $libPath -ManifestPath $manifestOnShare -InstallerRoot $driveLetter -LogFile $logFile
|
||||
$rc = $LASTEXITCODE
|
||||
Write-EnforceLog "Install-FromManifest returned $rc"
|
||||
}
|
||||
finally {
|
||||
net use $driveLetter /delete /y 2>$null | Out-Null
|
||||
Write-EnforceLog "Unmounted $driveLetter"
|
||||
Write-EnforceLog "=== CMM-Enforce session end ==="
|
||||
}
|
||||
|
||||
# Always return 0 so the scheduled task never shows "last run failed" noise.
|
||||
exit 0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Version": "2.0",
|
||||
"_comment": "CMM machine-app manifest. Consumed by both 09-Setup-CMM.ps1 (at imaging time, reading from C:\\CMM-Install\\) and CMM-Enforce.ps1 (on logon, reading from the tsgwp00525 share). Option 3 (patched-MSI) install strategy: we bypass Hexagon's Burn bundle entirely for PC-DMIS 2016 and 2019 R2. The main PC-DMIS MSIs have been patched via COM SQL UPDATE (msibuild-style) to force the Condition column to '0' for two custom actions: ProcessLicensingFromBundle (which would otherwise spin for ~13 minutes trying to activate against licensing.wilcoxassoc.com with empty credentials) and IsLicenseDateValid (which would fail the install with 'no valid license'). With both CAs disabled, the MSI installs cleanly with no license present; PCDLRN.exe installs and loads at runtime and the tech activates a real license via clmadmin.exe after imaging. VS 2010/2012 x64 runtime prereqs are handled by the shared preinstall.json VC++ x64 entries (which run before this manifest). CLM Tools 1.5/1.7 chained MSIs from the original bundles are intentionally SKIPPED; CLM 1.8.73 standalone provides the admin + runtime interfaces. Protect Viewer is kept because it's useful alongside PC-DMIS 2019 R2.",
|
||||
"_comment": "CMM machine-app manifest, imaging-time only. Consumed by 09-Setup-CMM.ps1 reading from C:\\CMM-Install\\. Ongoing enforcement is handled separately by GE-Enforce reading cmm/manifest.json from the tsgwp00525 share. Option 3 (patched-MSI) install strategy: we bypass Hexagon's Burn bundle entirely for PC-DMIS 2016 and 2019 R2. The main PC-DMIS MSIs have been patched via COM SQL UPDATE (msibuild-style) to force the Condition column to '0' for two custom actions: ProcessLicensingFromBundle (which would otherwise spin for ~13 minutes trying to activate against licensing.wilcoxassoc.com with empty credentials) and IsLicenseDateValid (which would fail the install with 'no valid license'). With both CAs disabled, the MSI installs cleanly with no license present; PCDLRN.exe installs and loads at runtime and the tech activates a real license via clmadmin.exe after imaging. VS 2010/2012 x64 runtime prereqs are handled by the shared preinstall.json VC++ x64 entries (which run before this manifest). CLM Tools 1.5/1.7 chained MSIs from the original bundles are intentionally SKIPPED; CLM 1.8.73 standalone provides the admin + runtime interfaces. Protect Viewer is kept because it's useful alongside PC-DMIS 2019 R2.",
|
||||
"Applications": [
|
||||
{
|
||||
"_comment": "PC-DMIS 2016 main MSI (PATCHED). ProcessLicensingFromBundle + IsLicenseDateValid custom actions have been pre-disabled by SQL UPDATE of InstallExecuteSequence.Condition to '0'. Install args: INSTALLFOLDER/APPLICATIONFOLDER paths have embedded double quotes to survive the runner's command-line concatenation when the path contains spaces. USINGWPFINSTALLER=1 mirrors the Burn bundle default and ensures HandleLicenseChoice CA (seq 783) stays skipped. HEIP=0 disables Hexagon telemetry. INSTALLPDFCONVERTER=0 skips the Nitro PDF converter. The patched MSI has a HashMismatch signature, which is expected and accepted by Windows Installer in /qn mode.",
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
# Install-FromManifest.ps1 - Generic JSON-manifest installer for CMM apps.
|
||||
#
|
||||
# Duplicated (by design, for now) from Shopfloor\00-PreInstall-MachineApps.ps1
|
||||
# with the Standard-PC-specific bits stripped out:
|
||||
# - no PCTypes filter (every CMM manifest entry is CMM-only)
|
||||
# - no site-name / machine-number placeholder replacement
|
||||
# - no KillAfterDetection shortcut (Hexagon Burn bundles exit cleanly)
|
||||
#
|
||||
# A future pass will unify both runners behind one library; keeping them
|
||||
# separate now avoids touching the Standard PC imaging path.
|
||||
#
|
||||
# Called from:
|
||||
# - 09-Setup-CMM.ps1 at imaging time with InstallerRoot=C:\CMM-Install
|
||||
# - CMM-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
|
||||
|
||||
# Synchronous write-through so each line hits disk immediately, mirroring
|
||||
# the preinstall runner's approach - protects forensic trail if an installer
|
||||
# triggers a reboot mid-loop.
|
||||
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)"
|
||||
|
||||
# Detection helper - mirrors the preinstall runner's logic. Registry path +
|
||||
# optional value name + optional exact value. The exact-value compare is how
|
||||
# version-pinned drift detection works: bumping DetectionValue in the manifest
|
||||
# makes the current install "fail" detection and reinstall.
|
||||
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. 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) {
|
||||
# Cancel any pending reboot scheduled by a previous installer, same as the
|
||||
# preinstall runner. Some Burn bundles schedule a reboot even with /norestart
|
||||
# (chained bootstrapper ignores the flag for some internal prereqs).
|
||||
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
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-InstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
|
||||
# Use Process.Start directly rather than Start-Process because PS 5.1's
|
||||
# Start-Process -PassThru disposes the process handle when control returns,
|
||||
# making ExitCode read as $null. Direct Process.Start gives us a live handle.
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
|
||||
# No per-app timeout here - PC-DMIS bundles can run 20+ minutes on slow
|
||||
# disks and we don't want to kill them mid-chain. The calling script
|
||||
# controls overall session timing.
|
||||
$proc.WaitForExit()
|
||||
$exitCode = $proc.ExitCode
|
||||
|
||||
# Burn and MSI exit codes:
|
||||
# 0 success
|
||||
# 1641 success, reboot initiated
|
||||
# 3010 success, reboot pending
|
||||
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" -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
|
||||
Reference in New Issue
Block a user