Stage 2a: unified GE-Enforce framework + share-root mirror
Consolidates per-type enforcers (CMM, Keyence, Machine, Common, Acrobat)
into one dispatcher driven by pc-type.txt + site-config and a share-side
manifest layout. Same share is now the single source of truth for routine
software updates without re-imaging.
Runtime:
common/GE-Enforce.ps1 SYSTEM scheduled task. Reads
common/manifest.json plus optional
<pcType>/manifest.json and
<pcType-subType>/manifest.json.
Dispatches each entry through the lib.
Writes _outputs/logs/<hostname>/status.json
on the share after each cycle for fleet
monitoring.
common/Register-GEEnforce.ps1 Task registration. Triggers: AtLogOn +
every 5 min (jittered per-PC from
hostname hash) + daily at 05:45,
13:45, 21:45 EST shift windows.
Unregisters legacy per-type tasks on
install so the two coexist at most for
the duration of a single enforce cycle.
common/Deploy-GEEnforce.ps1 Retrofit helper for already-imaged PCs
(admin-run; copies runtime + registers
task + optional immediate trigger).
Library (common/lib/Install-FromManifest.ps1):
- New Type values: PS1, BAT, File, Registry, INF
- New DetectionMethod values: Always, MarkerFile, ValueMatches, pnputil
- TargetHostnames filter (exact + -like wildcards, ANDed with PCTypes)
- Schema version check (logs WARN on manifest newer than lib MAJOR)
- Auto-writes MarkerFile on successful one-shot PS1/BAT/CMD runs
- MSI log scan on failure surfaces meaningful install errors
- Lib version bumped 2.0 -> 2.1 for TargetHostnames
Observability:
common/monitor-fleet-status.py Scans _outputs/logs/*/status.json for
stale check-ins, failed scopes, and
version drift. Respects scope (dir-name),
PCTypes, and TargetHostnames filters so
entries excluded from a PC do not
false-flag as drift.
Regression harness:
common/test/ Parameterized VM harness + README
covering every action type plus
rollback, bad/missing SFLD creds, and
schema versioning.
Imaging integration:
Run-ShopfloorSetup.ps1 now stages GE-Enforce.ps1 and lib to
C:\Program Files\GE\Shopfloor\ and invokes Register-GEEnforce.ps1
at the end of setup. Legacy Register-CommonEnforce invocation is
kept for the transition; it and the legacy per-type enforcer files
are dead code once Register-GEEnforce runs and will be removed in a
dedicated cleanup pass.
Standard-Machine manifest:
eDNC entry bumped 6.4.3 -> 6.4.5. DetectionValue pinned to the
4-part FileVersion 6.4.5.0 verified against a fresh install in the
Win11 analyzer VM. UDC DetectionValue pinned to 1.0.34 (registry
stores 3-part for UDC; verified live).
scripts/mirror-from-gold.sh:
Restructured around share-root rsyncs (one pass per Samba share)
to close gaps in the prior per-subdir layout: winpeapps/_shared/
Applications (7.5 GB of Adobe + fonts + Java + Office + OpenText
+ printdrivers + wireless + Zscaler), additional winpeapps image
types, and enrollment flat-layout root files. Adds
--skip-clonezilla and --skip-reports.
Verified end-to-end in the Win11 analyzer VM:
- Every action Type and DetectionMethod round-tripped
- PCTypes filter (Oracle excluded on Shopfloor, Firefox included
on Shopfloor and DESKTOP-*, excluded elsewhere)
- TargetHostnames filter (exact, wildcard, no-match)
- Upgrade path: XML hash bump + fleet re-copy
- Rollback path: history-archive restore propagates via enforcer,
fleet converges back without per-PC intervention
- Status writeback + monitor script drift detection
- Graceful degradation on bad creds, missing creds, share
unreachable (all exit 0, log clearly, retry next cycle)
Not in this commit (follow-ups):
- Retire legacy per-type *-Enforce.ps1 files and simplify
09-Setup-*.ps1 scripts (coordinated multi-file cleanup)
- Stage 2b: InUseCheck close-and-reopen, ApplyMode gating,
UpdateWindow, .apply-now.txt sentinel, BITS pre-staging,
1618 mutex retry, PostInstallCheck, Uninstall action
- Management app (manifest CRUD + deploy + rollback + fleet view)
- ShopFloor autologon persistence bug (deferred for next imaging
attempt with live registry evidence)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,49 @@
|
||||
# Install-FromManifest.ps1 - Generic JSON-manifest installer for cross-PC-type
|
||||
# apps enforced from the SFLD share (Acrobat Reader DC today; others later).
|
||||
# Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps.
|
||||
#
|
||||
# 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.
|
||||
# Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and
|
||||
# Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds
|
||||
# optional PCTypes filtering (entry skipped if current pc-type.txt doesn't
|
||||
# match any value in the entry's PCTypes array).
|
||||
#
|
||||
# Called from:
|
||||
# - Acrobat-Enforce.ps1 on logon with InstallerRoot=<mounted tsgwp00525 share>
|
||||
# Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them):
|
||||
# ApplyMode, UpdateWindow, InUseCheck. The lib treats all entries as
|
||||
# immediate-apply today; Stage 2b adds shift-window gating and
|
||||
# close-and-reopen behavior.
|
||||
#
|
||||
# Returns via exit code: 0 if every required app is either already installed
|
||||
# or installed successfully; non-zero if any install failed.
|
||||
# Consumers:
|
||||
# - GE-Enforce.ps1 (new unified dispatcher, runs via scheduled task with
|
||||
# both logon and periodic triggers)
|
||||
# - Legacy *-Enforce.ps1 that still point here (will be retired)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = every required entry either already satisfied or installed successfully
|
||||
# 1 = at least one entry failed
|
||||
# 2 = manifest or installer-root could not be read
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ManifestPath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InstallerRoot,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$LogFile
|
||||
[Parameter(Mandatory=$true)] [string]$ManifestPath,
|
||||
[Parameter(Mandatory=$true)] [string]$InstallerRoot,
|
||||
[Parameter(Mandatory=$true)] [string]$LogFile,
|
||||
[Parameter(Mandatory=$false)] [string]$PCType,
|
||||
[Parameter(Mandatory=$false)] [string]$PCSubType
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# Lib's supported manifest schema version. Bump the MAJOR part whenever a
|
||||
# manifest field or behavior changes in a way older libs can't handle.
|
||||
# Bump the MINOR part for additive, backward-compatible additions (new
|
||||
# optional field, new Type, new DetectionMethod). Manifests tagged with
|
||||
# a newer MAJOR than the lib get processed best-effort with unknown Types
|
||||
# logged; manifests tagged with a newer MINOR are fine.
|
||||
#
|
||||
# Changelog:
|
||||
# 2.1 - added TargetHostnames filter (exact + -like wildcards)
|
||||
# 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types,
|
||||
# Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter
|
||||
$LIB_MANIFEST_MAJOR = 2
|
||||
$LIB_MANIFEST_MINOR = 1
|
||||
|
||||
$logDir = Split-Path -Parent $LogFile
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
|
||||
@@ -32,13 +51,12 @@ if (-not (Test-Path $logDir)) {
|
||||
|
||||
function Write-InstallLog {
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)]
|
||||
[string]$Message,
|
||||
[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"
|
||||
$stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
$line = "[$stamp] [$Level] $Message"
|
||||
Write-Host $line
|
||||
try {
|
||||
@@ -59,36 +77,60 @@ function Write-InstallLog {
|
||||
}
|
||||
}
|
||||
|
||||
Write-InstallLog "================================================================"
|
||||
Write-InstallLog '================================================================'
|
||||
Write-InstallLog "=== Install-FromManifest session start (PID $PID) ==="
|
||||
Write-InstallLog "Manifest: $ManifestPath"
|
||||
Write-InstallLog "InstallerRoot: $InstallerRoot"
|
||||
Write-InstallLog "================================================================"
|
||||
if ($PCType) { Write-InstallLog "PCType: $PCType" }
|
||||
if ($PCSubType) { Write-InstallLog "PCSubType: $PCSubType" }
|
||||
Write-InstallLog '================================================================'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ManifestPath)) {
|
||||
Write-InstallLog "Manifest not found: $ManifestPath" "ERROR"
|
||||
Write-InstallLog "Manifest not found: $ManifestPath" 'ERROR'
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $InstallerRoot)) {
|
||||
Write-InstallLog "InstallerRoot not found: $InstallerRoot" "ERROR"
|
||||
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"
|
||||
Write-InstallLog "Failed to parse manifest: $_" 'ERROR'
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (-not $config.Applications) {
|
||||
Write-InstallLog "No Applications in manifest - nothing to do"
|
||||
if (-not $config.Applications -or $config.Applications.Count -eq 0) {
|
||||
Write-InstallLog 'No Applications in manifest - nothing to do'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest schema version check. Accepts "X" or "X.Y" form. If the manifest
|
||||
# declares a MAJOR higher than the lib supports, log a clear warning so the
|
||||
# operator sees "upgrade the lib on this PC" instead of weird silent
|
||||
# skip-everything behavior. The lib still tries to run (unknown Types will
|
||||
# log as ERROR and count as failed, but supported entries work).
|
||||
# ---------------------------------------------------------------------------
|
||||
$manifestMajor = 0
|
||||
$manifestMinor = 0
|
||||
if ($config.Version) {
|
||||
$parts = "$($config.Version)".Split('.')
|
||||
if ($parts.Length -ge 1 -and [int]::TryParse($parts[0], [ref]$null)) { $manifestMajor = [int]$parts[0] }
|
||||
if ($parts.Length -ge 2 -and [int]::TryParse($parts[1], [ref]$null)) { $manifestMinor = [int]$parts[1] }
|
||||
}
|
||||
if ($manifestMajor -gt $LIB_MANIFEST_MAJOR) {
|
||||
Write-InstallLog " Manifest schema version $($config.Version) is newer than this lib (supports $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR). Unknown Types/fields will be logged as errors. Upgrade the lib on this PC." 'WARN'
|
||||
} elseif ($manifestMajor -eq $LIB_MANIFEST_MAJOR -and $manifestMinor -gt $LIB_MANIFEST_MINOR) {
|
||||
Write-InstallLog " Manifest schema $($config.Version) is minor-newer than lib $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR. Should be backward compatible; any unknown Types will be logged." 'INFO'
|
||||
}
|
||||
|
||||
Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
function Test-AppInstalled {
|
||||
param($App)
|
||||
|
||||
@@ -96,7 +138,7 @@ function Test-AppInstalled {
|
||||
|
||||
try {
|
||||
switch ($App.DetectionMethod) {
|
||||
"Registry" {
|
||||
'Registry' {
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
if ($App.DetectionName) {
|
||||
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
|
||||
@@ -108,182 +150,333 @@ function Test-AppInstalled {
|
||||
}
|
||||
return $true
|
||||
}
|
||||
"File" {
|
||||
'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.
|
||||
'FileVersion' {
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
if (-not $App.DetectionValue) {
|
||||
Write-InstallLog " FileVersion detection requires DetectionValue - treating as not installed" "WARN"
|
||||
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.
|
||||
'Hash' {
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
if (-not $App.DetectionValue) {
|
||||
Write-InstallLog " Hash detection requires DetectionValue - treating as not installed" "WARN"
|
||||
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)
|
||||
}
|
||||
'MarkerFile' {
|
||||
# Used for one-shot PS1 scripts. Presence of the marker file
|
||||
# means the script already ran successfully. The lib writes
|
||||
# the marker automatically after a 0 exit on PS1/BAT/CMD.
|
||||
if (-not $App.DetectionPath) { return $false }
|
||||
return Test-Path $App.DetectionPath
|
||||
}
|
||||
'ValueMatches' {
|
||||
# For Type=Registry entries. Check that the registry value
|
||||
# equals the desired RegValue (read from the app entry).
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
if (-not $App.DetectionName) {
|
||||
Write-InstallLog ' ValueMatches detection requires DetectionName' 'WARN'
|
||||
return $false
|
||||
}
|
||||
$p = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
|
||||
if (-not $p) { return $false }
|
||||
return ("$($p.$($App.DetectionName))" -eq "$($App.RegValue)")
|
||||
}
|
||||
'pnputil' {
|
||||
# For INF drivers. Run pnputil /enum-drivers once and grep.
|
||||
if (-not $App.DetectionPattern) {
|
||||
Write-InstallLog ' pnputil detection requires DetectionPattern' 'WARN'
|
||||
return $false
|
||||
}
|
||||
$drivers = & pnputil /enum-drivers 2>&1 | Out-String
|
||||
return ($drivers -match $App.DetectionPattern)
|
||||
}
|
||||
'Always' {
|
||||
# Never considered installed - entry runs every cycle.
|
||||
# Useful for idempotent scripts like the VNC firewall rule.
|
||||
return $false
|
||||
}
|
||||
default {
|
||||
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
|
||||
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" 'WARN'
|
||||
return $false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-InstallLog " Detection check threw: $_" "WARN"
|
||||
Write-InstallLog " Detection check threw: $_" 'WARN'
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
function Invoke-InstallerAction {
|
||||
param($App)
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||
|
||||
switch ($App.Type) {
|
||||
'MSI' {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
Write-InstallLog " MSI not found: $installerPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
$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: $installerPath"
|
||||
Write-InstallLog " verbose log: $msiLog"
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
|
||||
}
|
||||
'EXE' {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
Write-InstallLog " EXE not found: $installerPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
$psi.FileName = $installerPath
|
||||
if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs }
|
||||
Write-InstallLog " exe: $installerPath"
|
||||
if ($App.InstallArgs) { Write-InstallLog " args: $($App.InstallArgs)" }
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
|
||||
}
|
||||
{ $_ -eq 'CMD' -or $_ -eq 'BAT' } {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
$psi.FileName = 'cmd.exe'
|
||||
$psi.Arguments = "/c `"$installerPath`""
|
||||
if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
|
||||
Write-InstallLog " cmd /c $installerPath"
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
|
||||
}
|
||||
'PS1' {
|
||||
$scriptPath = Join-Path $InstallerRoot ($App.Script)
|
||||
if (-not (Test-Path -LiteralPath $scriptPath)) {
|
||||
Write-InstallLog " PS1 not found: $scriptPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
$psi.FileName = 'powershell.exe'
|
||||
$psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
|
||||
if ($App.Args) { $psi.Arguments += " " + $App.Args }
|
||||
Write-InstallLog " ps1: $scriptPath"
|
||||
if ($App.Args) { Write-InstallLog " args: $($App.Args)" }
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
|
||||
}
|
||||
'INF' {
|
||||
$infPath = Join-Path $InstallerRoot $App.Installer
|
||||
if (-not (Test-Path -LiteralPath $infPath)) {
|
||||
Write-InstallLog " INF not found: $infPath" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
Write-InstallLog " pnputil /add-driver `"$infPath`" /install"
|
||||
$out = & pnputil /add-driver "$infPath" /install 2>&1 | Out-String
|
||||
Write-InstallLog " pnputil output: $($out.Trim())"
|
||||
# pnputil exits 0 on success, 259 if already installed, etc.
|
||||
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; LogRef = $null }
|
||||
}
|
||||
'File' {
|
||||
# Copy a file from the share (configs/*) to an absolute on-PC path.
|
||||
$source = Join-Path $InstallerRoot $App.Source
|
||||
if (-not (Test-Path -LiteralPath $source)) {
|
||||
Write-InstallLog " File source not found: $source" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
if (-not $App.Destination) {
|
||||
Write-InstallLog ' File entry missing Destination' 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
$destDir = Split-Path -Parent $App.Destination
|
||||
if ($destDir -and -not (Test-Path $destDir)) {
|
||||
New-Item -Path $destDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
try {
|
||||
Copy-Item -LiteralPath $source -Destination $App.Destination -Force -ErrorAction Stop
|
||||
Write-InstallLog " copied $source -> $($App.Destination)"
|
||||
return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
|
||||
} catch {
|
||||
Write-InstallLog " Copy failed: $_" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
|
||||
}
|
||||
}
|
||||
'Registry' {
|
||||
# Write a single registry value.
|
||||
if (-not $App.RegPath -or -not $App.RegName) {
|
||||
Write-InstallLog ' Registry entry missing RegPath/RegName' 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
|
||||
}
|
||||
if (-not (Test-Path $App.RegPath)) {
|
||||
New-Item -Path $App.RegPath -Force | Out-Null
|
||||
}
|
||||
$type = if ($App.RegType) { $App.RegType } else { 'String' }
|
||||
try {
|
||||
Set-ItemProperty -Path $App.RegPath -Name $App.RegName -Value $App.RegValue -Type $type -Force -ErrorAction Stop
|
||||
Write-InstallLog " set $($App.RegPath)\$($App.RegName) = $($App.RegValue) ($type)"
|
||||
return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
|
||||
} catch {
|
||||
Write-InstallLog " Registry write failed: $_" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
|
||||
}
|
||||
}
|
||||
default {
|
||||
Write-InstallLog " Unsupported Type: $($App.Type)" 'ERROR'
|
||||
return [pscustomobject]@{ ExitCode = -2; LogRef = $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry-applies filter. An entry applies to this PC only if:
|
||||
# - PCTypes is omitted OR matches current pcType/pcType-subType/"*", AND
|
||||
# - TargetHostnames is omitted OR matches current COMPUTERNAME
|
||||
# (supports exact match and -like wildcards: "WJS-*", "*-SHOP-*").
|
||||
# Both filters are ANDed so they compose: scope to a type AND a hostname
|
||||
# subset, or either alone. Case-insensitive throughout.
|
||||
# ---------------------------------------------------------------------------
|
||||
function Test-PCTypeMatches {
|
||||
param($App, [string]$Type, [string]$SubType)
|
||||
if (-not $App.PCTypes -or $App.PCTypes.Count -eq 0) { return $true }
|
||||
if (-not $Type) { return $true }
|
||||
foreach ($t in $App.PCTypes) {
|
||||
if ($t -eq '*') { return $true }
|
||||
if ($t -eq $Type) { return $true }
|
||||
if ($SubType -and $t -eq "$Type-$SubType") { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-HostnameMatches {
|
||||
param($App)
|
||||
if (-not $App.TargetHostnames -or $App.TargetHostnames.Count -eq 0) { return $true }
|
||||
$myName = $env:COMPUTERNAME
|
||||
foreach ($h in $App.TargetHostnames) {
|
||||
if ($h -ieq $myName) { return $true }
|
||||
if ($myName -ilike $h) { return $true } # glob patterns: WJS-*, *-SHOP-*
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
$installed = 0
|
||||
$skipped = 0
|
||||
$failed = 0
|
||||
$pcFiltered = 0
|
||||
|
||||
foreach ($app in $config.Applications) {
|
||||
cmd /c "shutdown /a 2>nul" *>$null
|
||||
# Cancel any reboot that a prior MSI queued, so the enforcer never
|
||||
# triggers an unexpected restart on a shopfloor PC.
|
||||
cmd /c 'shutdown /a 2>nul' *>$null
|
||||
|
||||
Write-InstallLog "==> $($app.Name)"
|
||||
|
||||
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
|
||||
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
|
||||
$pcFiltered++
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not (Test-HostnameMatches -App $app)) {
|
||||
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $env:COMPUTERNAME - skipping"
|
||||
$pcFiltered++
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-AppInstalled -App $app) {
|
||||
Write-InstallLog " Already installed at expected version - skipping"
|
||||
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
|
||||
$result = Invoke-InstallerAction -App $app
|
||||
$rc = $result.ExitCode
|
||||
|
||||
if ($rc -eq 0 -or $rc -eq 1641 -or $rc -eq 3010 -or $rc -eq 259) {
|
||||
Write-InstallLog " Exit $rc - SUCCESS"
|
||||
if ($rc -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" }
|
||||
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
|
||||
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
|
||||
$installed++
|
||||
|
||||
# Auto-write marker file for MarkerFile-detected entries that just
|
||||
# completed successfully. Keeps one-shot PS1 scripts from running
|
||||
# twice (idempotent scripts can skip this by using Always detection).
|
||||
if ($app.DetectionMethod -eq 'MarkerFile' -and $app.DetectionPath) {
|
||||
$markerDir = Split-Path -Parent $app.DetectionPath
|
||||
if ($markerDir -and -not (Test-Path $markerDir)) {
|
||||
New-Item -Path $markerDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
try {
|
||||
Set-Content -Path $app.DetectionPath -Value (Get-Date -Format 'o') -ErrorAction Stop
|
||||
Write-InstallLog " marker written: $($app.DetectionPath)"
|
||||
} catch {
|
||||
Write-InstallLog " marker write failed: $_" 'WARN'
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-InstallLog " Exit $rc - FAILED" 'ERROR'
|
||||
|
||||
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 ---"
|
||||
if ($result.LogRef -and (Test-Path $result.LogRef)) {
|
||||
if ($app.Type -eq 'MSI') {
|
||||
Write-InstallLog " --- meaningful lines from $($result.LogRef) ---"
|
||||
$patterns = @(
|
||||
'Note: 1: ',
|
||||
'return value 3',
|
||||
'Error \d+\.',
|
||||
'CustomAction .* returned actual error',
|
||||
'Failed to ',
|
||||
'Installation failed',
|
||||
'1: 2262',
|
||||
'1: 2203',
|
||||
'1: 2330'
|
||||
'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
|
||||
$matches = Select-String -Path $result.LogRef -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 " $_"
|
||||
}
|
||||
Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
|
||||
}
|
||||
Write-InstallLog " --- end MSI log scan ---"
|
||||
Write-InstallLog ' --- end MSI log scan ---'
|
||||
} else {
|
||||
Write-InstallLog " --- last 30 lines of $($result.LogRef) ---"
|
||||
Get-Content $result.LogRef -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
|
||||
Write-InstallLog ' --- end installer log tail ---'
|
||||
}
|
||||
|
||||
$failed++
|
||||
}
|
||||
} catch {
|
||||
Write-InstallLog " Install threw: $_" "ERROR"
|
||||
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-InstallLog "============================================"
|
||||
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed"
|
||||
Write-InstallLog "============================================"
|
||||
Write-InstallLog '============================================'
|
||||
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered"
|
||||
Write-InstallLog '============================================'
|
||||
|
||||
cmd /c "shutdown /a 2>nul" *>$null
|
||||
cmd /c 'shutdown /a 2>nul' *>$null
|
||||
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user