diff --git a/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 b/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 index 855eb89..97cd9b0 100644 --- a/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 +++ b/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 @@ -286,13 +286,47 @@ Unregister-ScheduledTask -TaskName 'GE Re-enable Wired NICs' -Confirm:$false -Er } $commonSetupDir = Join-Path $PSScriptRoot 'common' + +# --- Register the unified GE-Enforce scheduled task --- +# Replaces the per-type legacy enforcers (CMM-Enforce, Keyence-Enforce, +# Machine-Enforce, Common-Enforce, Acrobat-Enforce). Register-GEEnforce.ps1 +# unregisters any of those legacy tasks before creating the new one, so +# running this after the legacy Register-* invocations below is harmless +# and race-free. Once a future repo cleanup retires the legacy Register-* +# scripts entirely, those invocations below can be removed. Until then we +# accept a brief moment of duplicate registration that Register-GEEnforce +# itself resolves. +$registerGE = Join-Path $commonSetupDir 'Register-GEEnforce.ps1' +if (Test-Path -LiteralPath $registerGE) { + Write-Host "" + Write-Host "=== Registering unified GE Shopfloor enforcer ===" + try { + $enforcerRuntime = Join-Path $commonSetupDir 'GE-Enforce.ps1' + $libSource = Join-Path $commonSetupDir 'lib\Install-FromManifest.ps1' + # Stage enforcer runtime so the scheduled task can reach it post-imaging. + $runtimeDir = 'C:\Program Files\GE\Shopfloor' + $runtimeLib = Join-Path $runtimeDir 'lib' + foreach ($d in @($runtimeDir, $runtimeLib)) { + if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null } + } + Copy-Item -LiteralPath $enforcerRuntime -Destination (Join-Path $runtimeDir 'GE-Enforce.ps1') -Force + Copy-Item -LiteralPath $libSource -Destination (Join-Path $runtimeLib 'Install-FromManifest.ps1') -Force + & $registerGE -EnforcerPath (Join-Path $runtimeDir 'GE-Enforce.ps1') + } catch { + Write-Warning "GE-Enforce registration failed: $_" + } +} else { + Write-Host "Register-GEEnforce.ps1 not found - skipping (legacy per-type enforcers remain active)" +} + +# Legacy Common enforcer: kept for the transition period; GE-Enforce +# unregisters the task it creates. Remove this block when the legacy +# Common-Enforce.ps1 is retired from the repo. $registerCommon = Join-Path $commonSetupDir 'Register-CommonEnforce.ps1' if (Test-Path -LiteralPath $registerCommon) { Write-Host "" - Write-Host "=== Registering Common Apps enforcer ===" + Write-Host "=== (legacy) Registering Common Apps enforcer - will be superseded by GE-Enforce ===" try { & $registerCommon } catch { Write-Warning "Common enforce registration failed: $_" } -} else { - Write-Host "Register-CommonEnforce.ps1 not found (optional) - skipping" } # Map S: drive on user logon for every account in BUILTIN\Users. The diff --git a/playbook/shopfloor-setup/Standard/machineapps-manifest.template.json b/playbook/shopfloor-setup/Standard/machineapps-manifest.template.json index 820e993..30bf76a 100644 --- a/playbook/shopfloor-setup/Standard/machineapps-manifest.template.json +++ b/playbook/shopfloor-setup/Standard/machineapps-manifest.template.json @@ -14,14 +14,14 @@ "DetectionValue": "REPLACE_WITH_PINNED_UDC_VERSION" }, { - "_comment": "eDNC. Ships with NTLARS bundled (NTLARS.exe lands at C:\\Program Files (x86)\\Dnc\\Common\\ as part of the same install), so no separate NTLARS entry is needed. SITESELECTED encodes the site (was a recurring bug in early shopfloor-setup scripts that omitted it). Adjust to your site's value if not West Jefferson. Detection uses FileVersion on DncMain.exe so version upgrades actually fire (File-existence detection on NTLARS would skip the upgrade because the file already exists from the prior version). Update workflow: drop the new MSI on the SFLD share, bump DetectionValue + Installer in this manifest to the new vendor-stamped FileVersion, and the next user logon installs it.", + "_comment": "eDNC 6.4.5. Ships with NTLARS bundled (NTLARS.exe lands at C:\\Program Files (x86)\\Dnc\\Common\\ as part of the same install), so no separate NTLARS entry is needed. SITESELECTED encodes the site (was a recurring bug in early shopfloor-setup scripts that omitted it). Adjust to your site's value if not West Jefferson. Detection uses FileVersion on DncMain.exe so version upgrades actually fire. The vendor stamps DncMain.exe with a 4-part version (e.g. '6.4.5.0'), not 3-part, so DetectionValue must be the exact 4-part string - an earlier 3-part value in this entry caused detection to always fail and the MSI reinstalled silently on every logon. Update workflow: drop the new MSI on the SFLD share, bump DetectionValue + Installer in this manifest to the new vendor-stamped FileVersion, and the next user logon installs it.", "Name": "eDNC (bundles NTLARS)", - "Installer": "eDNC-6.4.3.msi", + "Installer": "eDNC_6-4-5.msi", "Type": "MSI", "InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress SITESELECTED=\"West Jefferson\"", "DetectionMethod": "FileVersion", "DetectionPath": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", - "DetectionValue": "6.4.3" + "DetectionValue": "6.4.5.0" }, { "_comment": "Custom eMxInfo.txt (site-specific eDNC config). No vendor installer - the secret file lives on the SFLD share alongside the eDNC MSI. Install-eMxInfo.cmd copies it to both 32-bit and 64-bit eDNC Program Files paths. Hash detection catches both 'file missing' and 'file is a stale version'. Yearly rotation procedure: drop the new eMxInfo.txt on the share, recompute its SHA256 (PowerShell: (Get-FileHash .\\eMxInfo.txt -Algorithm SHA256).Hash), paste the new hash into DetectionValue here, save. Every Machine PC catches up on the next user logon. Content-sensitive: eMxInfo.txt must NEVER be committed to git (already in .gitignore).", diff --git a/playbook/shopfloor-setup/common/Deploy-GEEnforce.ps1 b/playbook/shopfloor-setup/common/Deploy-GEEnforce.ps1 new file mode 100644 index 0000000..8fae1fc --- /dev/null +++ b/playbook/shopfloor-setup/common/Deploy-GEEnforce.ps1 @@ -0,0 +1,97 @@ +# Deploy-GEEnforce.ps1 +# +# One-shot deploy of the GE-Enforce runtime + scheduled task to an already- +# imaged shopfloor PC. Use this to promote a PC from legacy per-type +# enforcers (CMM-Enforce, Keyence-Enforce, Machine-Enforce, etc.) to the +# unified GE-Enforce, without re-imaging. +# +# Usage (on target PC, as admin): +# powershell -ExecutionPolicy Bypass -File .\Deploy-GEEnforce.ps1 ` +# -SourceRoot '\\\\enforcer-stage' +# +# The SourceRoot must contain: +# GE-Enforce.ps1 +# lib\Install-FromManifest.ps1 +# +# For imaging-time deployment, Run-ShopfloorSetup.ps1 invokes +# Register-GEEnforce.ps1 directly. This script is specifically for +# retrofitting live PCs. + +param( + [Parameter(Mandatory=$true)] + [string]$SourceRoot, + + [string]$InstallRoot = 'C:\Program Files\GE\Shopfloor', + + [switch]$TriggerImmediate +) + +$ErrorActionPreference = 'Stop' + +function Write-DeployLog { param([string]$m) Write-Host "[Deploy-GEEnforce] $m" } + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrators')) { + throw 'Must run as Administrator.' +} + +Write-DeployLog "SourceRoot: $SourceRoot" +Write-DeployLog "InstallRoot: $InstallRoot" + +# ---- 1. Validate source ---- +$srcEnforcer = Join-Path $SourceRoot 'GE-Enforce.ps1' +$srcLib = Join-Path $SourceRoot 'lib\Install-FromManifest.ps1' +foreach ($p in @($srcEnforcer, $srcLib)) { + if (-not (Test-Path -LiteralPath $p)) { + throw "Missing source file: $p" + } +} + +# ---- 2. Stage runtime ---- +Write-DeployLog "Staging runtime to $InstallRoot" +$libDst = Join-Path $InstallRoot 'lib' +foreach ($d in @($InstallRoot, $libDst)) { + if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null } +} +Copy-Item -LiteralPath $srcEnforcer -Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force +Copy-Item -LiteralPath $srcLib -Destination (Join-Path $libDst 'Install-FromManifest.ps1') -Force +Write-DeployLog ' Runtime files copied' + +# ---- 3. Register scheduled task + unregister legacy ---- +$registerScript = Join-Path $SourceRoot 'Register-GEEnforce.ps1' +if (-not (Test-Path -LiteralPath $registerScript)) { + # Fall back to the copy that should sit next to this deploy script in the + # repo. In the common rollout, Register-GEEnforce.ps1 lives beside + # GE-Enforce.ps1 in $SourceRoot. + $registerScript = Join-Path (Split-Path -Parent $PSCommandPath) 'Register-GEEnforce.ps1' +} +if (-not (Test-Path -LiteralPath $registerScript)) { + throw "Cannot find Register-GEEnforce.ps1 in $SourceRoot or alongside this script." +} + +Write-DeployLog "Invoking $registerScript" +& $registerScript -EnforcerPath (Join-Path $InstallRoot 'GE-Enforce.ps1') + +# ---- 4. Optional: trigger one enforce cycle immediately ---- +if ($TriggerImmediate) { + Write-DeployLog 'Triggering GE Shopfloor Enforce now via schtasks /run' + & schtasks /run /tn 'GE Shopfloor Enforce' | Out-Null + Start-Sleep -Seconds 3 + $log = Get-ChildItem -Path 'C:\Logs\Shopfloor' -Filter 'enforce-*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($log) { + Write-DeployLog "Latest log: $($log.FullName) (watching for 30s)" + $end = (Get-Date).AddSeconds(30) + $lastSize = 0 + while ((Get-Date) -lt $end) { + $size = (Get-Item $log.FullName).Length + if ($size -gt $lastSize) { + Get-Content -Path $log.FullName -Tail 20 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } + Write-Host ' ---' + $lastSize = $size + } + Start-Sleep -Seconds 2 + } + } +} + +Write-DeployLog 'DONE. Verify with: schtasks /query /tn "GE Shopfloor Enforce" /v /fo list' diff --git a/playbook/shopfloor-setup/common/GE-Enforce.ps1 b/playbook/shopfloor-setup/common/GE-Enforce.ps1 new file mode 100644 index 0000000..4651a14 --- /dev/null +++ b/playbook/shopfloor-setup/common/GE-Enforce.ps1 @@ -0,0 +1,247 @@ +# GE-Enforce.ps1 - Unified shopfloor enforcer. +# +# Replaces the per-type enforcers (CMM-Enforce, Keyence-Enforce, +# Machine-Enforce, Common-Enforce, Acrobat-Enforce). Runs as a single SYSTEM +# scheduled task with multiple triggers (at-logon, periodic 5-min, and the +# three shift-change windows). +# +# On each invocation: +# 1. Reads C:\Enrollment\pc-type.txt and pc-subtype.txt. +# 2. Mounts the tsgwp00525 SFLD share using creds from HKLM. +# 3. Processes common\manifest.json (always, with per-entry PCTypes filter). +# 4. Processes \manifest.json if it exists (enforces type-specific +# apps like CMM, Keyence, Display, Keyence, etc.). +# 5. Processes -\manifest.json if it exists +# (the "standard-machine" case). +# +# Graceful degradation: +# - pc-type.txt missing -> log + exit 0 (PC is pre-imaging) +# - SFLD creds missing -> log + exit 0 (Azure DSC hasn't provisioned yet) +# - Share unreachable -> log + exit 0 (off-network, retry next cycle) +# - Per-entry install failure -> log + continue to next entry +# +# Always exits 0 so the scheduled task "last run result" stays clean. Truth +# is in the log: C:\Logs\Shopfloor\enforce-YYYYMMDD.log. + +$ErrorActionPreference = 'Continue' + +$installRoot = 'C:\Program Files\GE\Shopfloor' +$libPath = Join-Path $installRoot 'lib\Install-FromManifest.ps1' +$logDir = 'C:\Logs\Shopfloor' +$logFile = Join-Path $logDir ('enforce-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) +$driveLetter = 'W:' # distinct from S: (shopfloor share), T:/U: (legacy enforcers) + +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 "=== GE-Enforce session start (PID $PID, user $env:USERNAME) ===" +Write-EnforceLog '================================================================' + +# --- pc-type --- +$pcTypeFile = 'C:\Enrollment\pc-type.txt' +$pcSubTypeFile = 'C:\Enrollment\pc-subtype.txt' +if (-not (Test-Path $pcTypeFile)) { + Write-EnforceLog "pc-type.txt not found - PC is pre-imaging, nothing to enforce" + exit 0 +} +$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue).Trim() +$pcSubType = if (Test-Path $pcSubTypeFile) { + (Get-Content -LiteralPath $pcSubTypeFile -First 1 -ErrorAction SilentlyContinue).Trim() +} else { '' } +Write-EnforceLog "PCType: $pcType$(if ($pcSubType) { " / $pcSubType" })" + +# --- site-config --- +$siteConfigFile = 'C:\Enrollment\site-config.json' +if (-not (Test-Path $siteConfigFile)) { + Write-EnforceLog "site-config.json not found at $siteConfigFile" 'ERROR' + exit 0 +} +try { + $siteConfig = Get-Content -LiteralPath $siteConfigFile -Raw | ConvertFrom-Json +} catch { + Write-EnforceLog "site-config.json parse failed: $_" 'ERROR' + exit 0 +} + +$shopfloorShareRoot = $siteConfig.shopfloorShareRoot +if (-not $shopfloorShareRoot) { + # Fallback: derive from commonAppsSharePath if the new field isn't set. + $capp = $siteConfig.common.commonAppsSharePath + if ($capp) { + # \\...\shared\dt\shopfloor\common\apps -> \\...\shared\dt\shopfloor + $shopfloorShareRoot = ($capp -replace '\\common\\apps$', '') + } +} +if (-not $shopfloorShareRoot) { + Write-EnforceLog 'No shopfloorShareRoot derivable from site-config - nothing to enforce' 'ERROR' + exit 0 +} +Write-EnforceLog "Shopfloor share root: $shopfloorShareRoot" + +# --- SFLD credential lookup (written by Azure DSC) --- +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 = ($shopfloorShareRoot -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 next cycle" + exit 0 +} +Write-EnforceLog "Credential: $($cred.KeyName) (user: $($cred.Username))" + +# --- Mount --- +& net use $driveLetter /delete /y 2>$null | Out-Null +$netResult = & net use $driveLetter $shopfloorShareRoot /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 cycle.' + exit 0 +} +Write-EnforceLog "Mounted $shopfloorShareRoot as $driveLetter" + +try { + if (-not (Test-Path $libPath)) { + Write-EnforceLog "Install-FromManifest.ps1 not found at $libPath" 'ERROR' + return + } + + # --- Process manifests in order: common, per-type, per-type+subtype --- + $targets = @() + + $commonRoot = Join-Path $driveLetter 'common' + $commonManifest = Join-Path $commonRoot 'manifest.json' + if (Test-Path $commonManifest) { + $targets += [pscustomobject]@{ Label = 'common'; Manifest = $commonManifest; Root = $commonRoot } + } else { + Write-EnforceLog "common\manifest.json missing - skipping common scope" 'WARN' + } + + $typeDir = $pcType.ToLower() + if ($typeDir) { + $typeRoot = Join-Path $driveLetter $typeDir + $typeManifest = Join-Path $typeRoot 'manifest.json' + if (Test-Path $typeManifest) { + $targets += [pscustomobject]@{ Label = $pcType; Manifest = $typeManifest; Root = $typeRoot } + } else { + Write-EnforceLog "$typeDir\manifest.json not on share - no type-specific apps for $pcType" + } + } + + if ($pcSubType) { + $stDir = ($pcType + '-' + $pcSubType).ToLower() + $stRoot = Join-Path $driveLetter $stDir + $stManifest = Join-Path $stRoot 'manifest.json' + if (Test-Path $stManifest) { + $targets += [pscustomobject]@{ Label = "$pcType-$pcSubType"; Manifest = $stManifest; Root = $stRoot } + } + } + + $scopeResults = @() + foreach ($t in $targets) { + Write-EnforceLog "---- Processing scope: $($t.Label) ----" + Write-EnforceLog " Manifest: $($t.Manifest)" + Write-EnforceLog " Root: $($t.Root)" + & $libPath -ManifestPath $t.Manifest -InstallerRoot $t.Root -LogFile $logFile -PCType $pcType -PCSubType $pcSubType + $rc = $LASTEXITCODE + Write-EnforceLog " Install-FromManifest returned $rc" + $scopeResults += [pscustomobject]@{ Label = $t.Label; ExitCode = $rc } + } + + # ------------------------------------------------------------------ + # Status write-back to _outputs/logs//status.json on share. + # Consumed by the management app / fleet dashboard to answer + # "which PCs checked in, when, and what version did each install." + # Writes under the mounted drive using SFLD creds; gracefully + # continues if the share path is not writable. + # ------------------------------------------------------------------ + try { + $statusDir = Join-Path (Join-Path $driveLetter '_outputs') (Join-Path 'logs' $env:COMPUTERNAME) + if (-not (Test-Path $statusDir)) { + New-Item -Path $statusDir -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + $statusFile = Join-Path $statusDir 'status.json' + + # Walk each processed manifest and pull current DetectionValue per entry. + $installedVersions = @{} + foreach ($t in $targets) { + try { + $cfg = Get-Content -LiteralPath $t.Manifest -Raw | ConvertFrom-Json + foreach ($a in $cfg.Applications) { + if (-not $a.DetectionPath) { continue } + $key = "$($t.Label)/$($a.Name)" + $val = $null + switch ($a.DetectionMethod) { + 'Registry' { + if ((Test-Path $a.DetectionPath) -and $a.DetectionName) { + $p = Get-ItemProperty -Path $a.DetectionPath -Name $a.DetectionName -ErrorAction SilentlyContinue + if ($p) { $val = "$($p.$($a.DetectionName))" } + } + } + 'FileVersion' { + if (Test-Path $a.DetectionPath) { + $val = (Get-Item $a.DetectionPath).VersionInfo.FileVersion + } + } + 'File' { if (Test-Path $a.DetectionPath) { $val = 'present' } } + 'Hash' { if (Test-Path $a.DetectionPath) { $val = (Get-FileHash -Path $a.DetectionPath -Algorithm SHA256).Hash } } + 'MarkerFile' { if (Test-Path $a.DetectionPath) { $val = 'marker present' } } + 'Always' { $val = 'n/a (Always)' } + 'pnputil' { $val = 'pnputil-managed' } + } + $installedVersions[$key] = $val + } + } catch { + Write-EnforceLog " status: manifest introspection for $($t.Label) failed: $_" 'WARN' + } + } + + $status = [ordered]@{ + hostname = $env:COMPUTERNAME + lastCheckIn = (Get-Date).ToUniversalTime().ToString('o') + pcType = $pcType + pcSubType = $pcSubType + enforcerVersion = '2.0' + shopfloorShareRoot = $shopfloorShareRoot + scopesProcessed = $scopeResults + installedVersions = $installedVersions + } | ConvertTo-Json -Depth 6 + Set-Content -Path $statusFile -Value $status -Encoding ascii -ErrorAction Stop + Write-EnforceLog "Status written to $statusFile" + } catch { + Write-EnforceLog "Status write-back failed (share may be read-only for this user): $_" 'WARN' + } +} +finally { + & net use $driveLetter /delete /y 2>$null | Out-Null + Write-EnforceLog "Unmounted $driveLetter" + Write-EnforceLog '=== GE-Enforce session end ===' +} + +exit 0 diff --git a/playbook/shopfloor-setup/common/Register-GEEnforce.ps1 b/playbook/shopfloor-setup/common/Register-GEEnforce.ps1 new file mode 100644 index 0000000..81c154b --- /dev/null +++ b/playbook/shopfloor-setup/common/Register-GEEnforce.ps1 @@ -0,0 +1,101 @@ +# Register-GEEnforce.ps1 - Registers "GE Shopfloor Enforce" scheduled task. +# +# Idempotent: deletes an existing task with the same name first. Also +# unregisters the legacy per-type enforcer tasks so they don't race. +# +# Triggers (all run as SYSTEM, RunLevel=Highest): +# - AtLogOn for first-boot catch-up +# - Repetition every 5 min for fleet auto-update (jittered start offset +# in the first 5 min window to spread 200 PCs) +# - Shift-change windows 05:45, 13:45, 21:45 EST (30-min window each; +# handled by running the task at window start +# and letting the lib run anything not yet +# applied - Stage 2b will gate ApplyMode=Nightly +# entries to only fire inside a window). +# +# Called from Run-ShopfloorSetup.ps1 at imaging time. Also usable manually +# to re-register on an existing PC. + +param( + [string]$EnforcerPath = 'C:\Program Files\GE\Shopfloor\GE-Enforce.ps1' +) + +$ErrorActionPreference = 'Stop' +$taskName = 'GE Shopfloor Enforce' +$legacyTasks = @( + 'GE Common Enforce', + 'GE Acrobat Enforce', + 'GE Shopfloor Machine Apps Enforce', + 'GE Machine Enforce', + 'GE CMM Enforce', + 'GE Keyence Enforce' +) + +function Write-RegisterLog { param([string]$m) Write-Host "[Register-GEEnforce] $m" } + +# Unregister legacy per-type enforcers so only GE-Enforce drives the fleet. +foreach ($name in $legacyTasks) { + $t = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue + if ($t) { + Write-RegisterLog "Unregistering legacy task: $name" + Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue + } +} + +# Drop an existing copy of our own task (re-imaging idempotency). +$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if ($existing) { + Write-RegisterLog "Removing existing scheduled task: $taskName" + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} + +# --- Action --- +$action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$EnforcerPath`"" + +# --- Triggers --- +# Per-PC random offset [0, 5) min so 200 PCs don't all fire on :00/:05/:10/... +# Derived from hostname hash so the same PC always picks the same offset. +$hostHash = [System.BitConverter]::ToUInt32( + [System.Security.Cryptography.MD5]::Create().ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($env:COMPUTERNAME)), 0) +$offsetMin = $hostHash % 5 # 0..4 + +$startToday = (Get-Date -Hour 0 -Minute $offsetMin -Second 0).AddSeconds(0) + +$logonTrigger = New-ScheduledTaskTrigger -AtLogOn +# Periodic every 5 minutes, repeating indefinitely, starting at the offset +$periodicTrigger = New-ScheduledTaskTrigger -Once -At $startToday -RepetitionInterval (New-TimeSpan -Minutes 5) + +$shift1Trigger = New-ScheduledTaskTrigger -Daily -At '05:45' # 3-to-1 shift +$shift2Trigger = New-ScheduledTaskTrigger -Daily -At '13:45' # 1-to-2 +$shift3Trigger = New-ScheduledTaskTrigger -Daily -At '21:45' # 2-to-3 + +$triggers = @($logonTrigger, $periodicTrigger, $shift1Trigger, $shift2Trigger, $shift3Trigger) + +# --- Principal + Settings --- +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Hours 1) ` + -MultipleInstances IgnoreNew + +$description = "GE Shopfloor unified enforcer. Reads common + pc-type manifests from " + + "\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\, runs " + + "Install-FromManifest.ps1 against each. Periodic 5-min jitter + " + + "AtLogOn + 3x shift-change windows. Log: C:\Logs\Shopfloor\enforce-*.log" + +Register-ScheduledTask ` + -TaskName $taskName ` + -Action $action ` + -Trigger $triggers ` + -Principal $principal ` + -Settings $settings ` + -Description $description | Out-Null + +Write-RegisterLog "Registered '$taskName' (offset $offsetMin min)" +Write-RegisterLog " Triggers: AtLogOn, every 5min, 05:45, 13:45, 21:45" +Write-RegisterLog " Action: powershell -File $EnforcerPath" diff --git a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 index 1022cfb..428eff6 100644 --- a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 +++ b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 @@ -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= +# 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 diff --git a/playbook/shopfloor-setup/common/monitor-fleet-status.py b/playbook/shopfloor-setup/common/monitor-fleet-status.py new file mode 100755 index 0000000..02cd77d --- /dev/null +++ b/playbook/shopfloor-setup/common/monitor-fleet-status.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""monitor-fleet-status.py + +Reads status.json writebacks from the shopfloor enforcer output tree and +flags: + - PCs that haven't checked in within a stale-threshold window + - PCs with any failed scope from their last run + - Expected-vs-installed version mismatches (drift) when --manifests is + supplied + +Designed to run as a cron job on the PXE server (or any box with read +access to the share). Prints plaintext report to stdout; non-zero exit +code when anything needs attention so it's trivial to wrap in an alerting +script. + +Usage: + ./monitor-fleet-status.py --status-root /path/to/_outputs/logs + ./monitor-fleet-status.py --status-root /.../_outputs/logs --stale-hours 24 + ./monitor-fleet-status.py --status-root /.../_outputs/logs \\ + --manifests /.../common/manifest.json /.../cmm/manifest.json + +Typical cron (runs hourly, mails the root user on any output): + 0 * * * * camp /home/camp/bin/monitor-fleet-status.py \\ + --status-root /home/camp/pxe-images/tsgwp00525-v2/shared/dt/shopfloor/_outputs/logs \\ + --stale-hours 24 2>&1 | tail -100 +""" +from __future__ import annotations +import argparse +import datetime as dt +import fnmatch +import json +import pathlib +import sys +from typing import Any, Iterable + + +def load_json(path: pathlib.Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text()) + except Exception as e: + print(f"[!] {path}: parse failed: {e}", file=sys.stderr) + return None + + +def age_hours(iso_utc: str) -> float | None: + try: + t = dt.datetime.fromisoformat(iso_utc.replace('Z', '+00:00')) + now = dt.datetime.now(dt.timezone.utc) + return (now - t).total_seconds() / 3600.0 + except Exception: + return None + + +def load_manifest_expectations(paths: Iterable[pathlib.Path]) -> list[dict[str, Any]]: + """Load manifest entries with enough metadata to know which PCs each + entry should apply to. Returns a list of dicts, one per entry that has + a DetectionValue: + { key: "scope/Name", expected: "...", scope: "common||-", + pctypes: [...], target_hostnames: [...] } + Scope comes from the manifest file's parent directory name and is + treated as an implicit PC-type filter (parallels the lib's per-scope + dispatch in GE-Enforce.ps1). + """ + out: list[dict[str, Any]] = [] + for p in paths: + m = load_json(p) + if not m: + continue + scope = p.parent.name + for app in m.get('Applications', []): + name = app.get('Name') + val = app.get('DetectionValue') + if not (name and val): + continue + out.append({ + 'key': f"{scope}/{name}", + 'expected': val, + 'scope': scope, + 'pctypes': app.get('PCTypes') or [], + 'target_hostnames': app.get('TargetHostnames') or [], + }) + return out + + +def scope_applies_to_host(scope: str, pc_type: str, pc_sub_type: str) -> bool: + """Mirror GE-Enforce.ps1's per-scope dispatch: + common -> applied to every PC type + -> only when pc-type.txt matches + - -> only when pc-type matches AND subtype matches + Case-insensitive. + """ + s = scope.lower() + if s in ('common', ''): + return True + if '-' in s: + t, sub = s.split('-', 1) + return (t == pc_type and sub == pc_sub_type) + return s == pc_type + + +def entry_applies_to_host(entry: dict[str, Any], + pc_type: str | None, + pc_sub_type: str | None, + hostname: str) -> bool: + """Mirror the lib's entry-applies filter: scope + PCTypes + TargetHostnames, + all ANDed. Drift checks only flag entries that should have actually been + applied on this PC. + """ + pc_type = (pc_type or '').lower() + pc_sub_type = (pc_sub_type or '').lower() + hostname_lc = hostname.lower() + + # Scope filter: per-type manifests are implicitly scoped by the dir name. + if not scope_applies_to_host(entry.get('scope', ''), pc_type, pc_sub_type): + return False + + # PCTypes filter (explicit; applies within a scope): if set, PC must match. + pctypes = entry.get('pctypes') or [] + if pctypes: + if not pc_type: + return False + matched = False + for t in pctypes: + t_lc = t.lower() + if t_lc == '*': matched = True; break + if t_lc == pc_type: matched = True; break + if pc_sub_type and t_lc == f"{pc_type}-{pc_sub_type}": + matched = True; break + if not matched: + return False + + # TargetHostnames filter: if set, hostname must match exact or glob. + target_hosts = entry.get('target_hostnames') or [] + if target_hosts: + matched = False + for h in target_hosts: + h_lc = h.lower() + if h_lc == hostname_lc: matched = True; break + if fnmatch.fnmatch(hostname_lc, h_lc): matched = True; break + if not matched: + return False + + return True + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument('--status-root', required=True, + help='Root path like /_outputs/logs/') + ap.add_argument('--stale-hours', type=float, default=24.0, + help='Warn if a PC hasn\'t checked in in this many hours (default 24)') + ap.add_argument('--manifests', nargs='*', type=pathlib.Path, default=[], + help='Optional manifest paths; when set, drift between manifest ' + 'DetectionValue and PC-reported installedVersion is flagged.') + args = ap.parse_args() + + root = pathlib.Path(args.status_root) + if not root.is_dir(): + print(f"ERROR: status-root not found: {root}", file=sys.stderr) + return 2 + + expectations = load_manifest_expectations(args.manifests) + + issues = 0 + seen = 0 + stale = [] + failed = [] + drift = [] + + for host_dir in sorted(p for p in root.iterdir() if p.is_dir()): + status_file = host_dir / 'status.json' + if not status_file.exists(): + continue + st = load_json(status_file) + if not st: + continue + + host = st.get('hostname') or host_dir.name + pc_type = st.get('pcType') + sub_type = st.get('pcSubType') + seen += 1 + + # --- stale --- + hrs = age_hours(st.get('lastCheckIn', '')) + if hrs is None: + stale.append((host, 'unparseable timestamp')) + issues += 1 + elif hrs > args.stale_hours: + stale.append((host, f'{hrs:.1f}h since last check-in (> {args.stale_hours}h)')) + issues += 1 + + # --- per-scope failures --- + for scope in (st.get('scopesProcessed') or []): + if (scope.get('ExitCode') or 0) != 0: + failed.append((host, scope.get('Label'), scope.get('ExitCode'))) + issues += 1 + + # --- version drift --- + # Only check entries that should have applied to this PC. Entries + # with PCTypes or TargetHostnames filters that exclude this host + # are legitimately not installed and must not be flagged as drift. + if expectations: + installed = st.get('installedVersions', {}) or {} + for entry in expectations: + if not entry_applies_to_host(entry, pc_type, sub_type, host): + continue + key = entry['key'] + want = entry['expected'] + got = installed.get(key) + if got is None: + drift.append((host, key, 'missing', want)) + issues += 1 + elif str(got).upper() != str(want).upper(): + drift.append((host, key, got, want)) + issues += 1 + + # --- report --- + print(f"Fleet status monitor - scanned {seen} host(s) under {root}") + print(f" stale threshold: {args.stale_hours}h") + if args.manifests: + print(f" drift against: {', '.join(str(p) for p in args.manifests)}") + print() + + if not issues: + print('All checked-in hosts are healthy.') + return 0 + + if stale: + print(f"STALE CHECK-INS ({len(stale)}):") + for host, msg in stale: + print(f" {host}: {msg}") + print() + + if failed: + print(f"SCOPE FAILURES ({len(failed)}):") + for host, label, rc in failed: + print(f" {host}: scope '{label}' exited {rc}") + print() + + if drift: + print(f"VERSION DRIFT ({len(drift)}):") + for host, key, got, want in drift: + print(f" {host}: {key} got={got} want={want}") + print() + + print(f"Total issues: {issues}") + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/playbook/shopfloor-setup/common/test/README.md b/playbook/shopfloor-setup/common/test/README.md new file mode 100644 index 0000000..97e6c07 --- /dev/null +++ b/playbook/shopfloor-setup/common/test/README.md @@ -0,0 +1,77 @@ +# Shopfloor enforcer regression tests + +Lightweight harness for end-to-end validation of `GE-Enforce.ps1` + +`Install-FromManifest.ps1` against the v2 staging tree, using the Win11 +analyzer VM as a synthetic shopfloor PC. + +## Files + +- `vm-test-harness.ps1` — setup + invocation of GE-Enforce inside the VM. + Accepts `-PCType` and `-PCSubType` parameters. Creates + `C:\Enrollment\` stubs (pc-type.txt, pc-subtype.txt, site-config.json), + stages the enforcer runtime from `\\192.168.122.1\pxe-images\enforcer-stage\`, + injects a fake SFLD credential in `HKLM:\SOFTWARE\GE\SFLD\Credentials\samba` + pointing at the host's samba share as if it were tsgwp00525, then runs + `GE-Enforce.ps1` with output captured. + +## Prereqs + +- `win11` libvirt VM running, IP reachable at 192.168.122.210 +- qemu-guest-agent exec path available (`/tmp/guest-exec.sh`) +- host samba shares `pxe-images` + `windows-projects` writable by `camp` user +- enforcer staged at `/home/camp/pxe-images/enforcer-stage/` (via + `cp /common/GE-Enforce.ps1 /common/lib/Install-FromManifest.ps1 + /home/camp/pxe-images/enforcer-stage/`) +- v2 share staging at `/home/camp/pxe-images/tsgwp00525-v2/...` + +## Usage + +From the repo root on the host: + +```bash +# Round 1: Shopfloor scope (exercises common manifest, PCTypes filter for Oracle) +B64=$(iconv -f UTF-8 -t UTF-16LE common/test/vm-test-harness.ps1 | base64 -w0) +/tmp/guest-exec.sh powershell.exe "[\"-NoProfile\",\"-EncodedCommand\",\"$B64\"]" +``` + +Or with non-default pcType (wrap in a tiny outer script that sets parameters): + +```bash +cat > /tmp/round.ps1 <<'EOF' +$PCType = 'Standard' +$PCSubType = 'Machine' +EOF +sed -n '/^param(/,/^)/!p' common/test/vm-test-harness.ps1 >> /tmp/round.ps1 +B64=$(iconv -f UTF-8 -t UTF-16LE /tmp/round.ps1 | base64 -w0) +/tmp/guest-exec.sh powershell.exe "[\"-NoProfile\",\"-EncodedCommand\",\"$B64\"]" +``` + +## What each round validates + +| Round | pcType / pcSubType | Exercises | +|---|---|---| +| 1 | Shopfloor / — | common manifest only, PCTypes filter (Oracle skips) | +| 2 | Standard / Machine | common + standard-machine manifests, eDNC upgrade detection, UDC skip, eMxInfo cmd | +| 3 | Keyence / — | common + keyence manifest, VR-6000 MSI detection, pnputil INF detection | +| 4 | Display / — | common + display manifest, kiosk-setup CMD wrapper | +| 5 (composite) | Shopfloor with a corrupted manifest / bad SFLD creds / tampered local XML | graceful-degradation paths + upgrade/rollback via hash mismatch | + +See the main repo enforcer design doc (TBD) for scenario details. + +## Known cleanup after test runs + +- The harness intentionally leaves installed apps in place (Acrobat Reader DC, + WJF Defect Tracker, 3OF9 font, Edge site-list XML, Firefox if tested). + To reset to a clean baseline, revert the VM to the `clean-base` libvirt + snapshot: `virsh snapshot-revert win11 clean-base`. + +- Orphan `msiexec.exe` workers from long-running installs (UDC_Setup, + PC-DMIS) can leave the MSI mutex held, blocking the next install with + 1619/1618. Between rounds if you hit this: + + ```powershell + Get-Process -Name msiexec -ErrorAction SilentlyContinue | Stop-Process -Force + ``` + + Note: a Stage 2b lib improvement is planned to retry once on 1618 after + killing stale msiexec processes. diff --git a/playbook/shopfloor-setup/common/test/vm-test-harness.ps1 b/playbook/shopfloor-setup/common/test/vm-test-harness.ps1 new file mode 100644 index 0000000..2a55a4d --- /dev/null +++ b/playbook/shopfloor-setup/common/test/vm-test-harness.ps1 @@ -0,0 +1,62 @@ +param( + [string]$PCType = 'Shopfloor', + [string]$PCSubType = '' +) + +$ErrorActionPreference = 'Continue' + +Write-Host '================================================================' +Write-Host "=== VM test harness: pcType=$PCType subType=$PCSubType ===" +Write-Host '================================================================' + +& net use '\\192.168.122.1\pxe-images' /user:camp vos313 2>&1 | Out-Null +Write-Host "SMB mount status: $LASTEXITCODE" + +Write-Host '' +Write-Host '[setup] cleaning prior test state' +Remove-Item -Recurse -Force 'C:\Enrollment' -ErrorAction SilentlyContinue +Remove-Item -Recurse -Force 'C:\Program Files\GE\Shopfloor' -ErrorAction SilentlyContinue +Remove-Item -Recurse -Force 'C:\Logs\Shopfloor' -ErrorAction SilentlyContinue +Remove-Item -Path 'HKLM:\SOFTWARE\GE\SFLD\Credentials\samba' -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host '[setup] staging enforcer runtime' +New-Item -Path 'C:\Program Files\GE\Shopfloor\lib' -ItemType Directory -Force | Out-Null +Copy-Item '\\192.168.122.1\pxe-images\enforcer-stage\GE-Enforce.ps1' 'C:\Program Files\GE\Shopfloor\GE-Enforce.ps1' -Force +Copy-Item '\\192.168.122.1\pxe-images\enforcer-stage\lib\Install-FromManifest.ps1' 'C:\Program Files\GE\Shopfloor\lib\Install-FromManifest.ps1' -Force + +Write-Host "[setup] creating enrollment stubs (pcType=$PCType subType=$PCSubType)" +New-Item -Path 'C:\Enrollment' -ItemType Directory -Force | Out-Null +$PCType | Set-Content -Path 'C:\Enrollment\pc-type.txt' -Encoding ascii +$PCSubType | Set-Content -Path 'C:\Enrollment\pc-subtype.txt' -Encoding ascii +$siteConfig = @{ + shopfloorShareRoot = '\\192.168.122.1\pxe-images\tsgwp00525-v2\shared\dt\shopfloor' + siteName = 'West Jefferson (VM test)' +} | ConvertTo-Json -Depth 5 +$siteConfig | Set-Content -Path 'C:\Enrollment\site-config.json' -Encoding ascii + +Write-Host '[setup] injecting fake SFLD creds' +$credKey = 'HKLM:\SOFTWARE\GE\SFLD\Credentials\samba' +New-Item -Path $credKey -Force | Out-Null +New-ItemProperty -Path $credKey -Name TargetHost -Value '192.168.122.1' -PropertyType String -Force | Out-Null +New-ItemProperty -Path $credKey -Name Username -Value 'camp' -PropertyType String -Force | Out-Null +New-ItemProperty -Path $credKey -Name Password -Value 'vos313' -PropertyType String -Force | Out-Null + +Write-Host '' +Write-Host '================================================================' +Write-Host '=== Running GE-Enforce.ps1 ===' +Write-Host '================================================================' + +$stdoutFile = [System.IO.Path]::GetTempFileName() +$stderrFile = [System.IO.Path]::GetTempFileName() +$argString = '-NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\GE\Shopfloor\GE-Enforce.ps1"' +$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $argString ` + -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile +$rc = $proc.ExitCode +Write-Host "--- GE-Enforce stdout ---" +Get-Content -Path $stdoutFile -ErrorAction SilentlyContinue +Write-Host "--- GE-Enforce stderr ---" +Get-Content -Path $stderrFile -ErrorAction SilentlyContinue +Remove-Item $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue +Write-Host '' +Write-Host "GE-Enforce exited $rc" diff --git a/scripts/mirror-from-gold.sh b/scripts/mirror-from-gold.sh index 36090bb..b6480d5 100755 --- a/scripts/mirror-from-gold.sh +++ b/scripts/mirror-from-gold.sh @@ -1,24 +1,23 @@ #!/bin/bash # -# mirror-from-gold.sh - Replicate content from an existing PXE server (GOLD) -# onto a freshly-installed PXE server. +# mirror-from-gold.sh - Byte-identical mirror of /srv/samba/ from an existing +# PXE server (GOLD) onto a freshly-installed PXE server. # # Run this ON THE NEW PXE SERVER, pointing at the GOLD server's IP. -# It pulls Operating Systems, drivers, packages, custom installers, and -# Blancco assets that are NOT bundled on the USB installer. -# -# GOLD is on the taxonomy layout (pre-install/, installers-post/, blancco/, -# config/, ppkgs/, scripts/, shopfloor-setup/). Source and destination paths -# are identical per section, so sections can be added trivially as GOLD grows. +# It pulls every Samba share (winpeapps, enrollment, blancco-reports, +# clonezilla) wholesale so the new box matches GOLD regardless of whether +# content lives in the flat layout or the new taxonomy layout. # # Usage: # sudo ./mirror-from-gold.sh [options] # # Options: -# --skip-drivers Do not mirror Dell driver tree (saves ~178G). +# --skip-drivers Do not mirror Out-of-box Drivers trees (saves ~178G). # --skip-dell10 Do not mirror Dell_10 drivers (saves ~179G). # --skip-latitude Do not mirror Latitude drivers (saves ~48G). -# --skip-os Do not mirror Operating Systems (saves ~22G). +# --skip-os Do not mirror shared Operating Systems (saves ~22G). +# --skip-clonezilla Do not mirror clonezilla backup images (can be huge). +# --skip-reports Do not mirror blancco-reports history. # --dry-run Show what would transfer without doing it. # # Prereqs: @@ -49,15 +48,19 @@ SKIP_DRIVERS=0 SKIP_DELL10=0 SKIP_LATITUDE=0 SKIP_OS=0 +SKIP_CLONEZILLA=0 +SKIP_REPORTS=0 DRY_RUN="" while [ $# -gt 0 ]; do case "$1" in - --skip-drivers) SKIP_DRIVERS=1 ;; - --skip-dell10) SKIP_DELL10=1 ;; - --skip-latitude) SKIP_LATITUDE=1 ;; - --skip-os) SKIP_OS=1 ;; - --dry-run) DRY_RUN="--dry-run" ;; + --skip-drivers) SKIP_DRIVERS=1 ;; + --skip-dell10) SKIP_DELL10=1 ;; + --skip-latitude) SKIP_LATITUDE=1 ;; + --skip-os) SKIP_OS=1 ;; + --skip-clonezilla) SKIP_CLONEZILLA=1 ;; + --skip-reports) SKIP_REPORTS=1 ;; + --dry-run) DRY_RUN="--dry-run" ;; *) echo "Unknown option: $1"; exit 1 ;; esac shift @@ -104,84 +107,42 @@ mirror() { echo " WARNING: rsync exited rc=$? (likely a permissions issue on source); continuing" } -# ---------- Shared imaging content (winpeapps/_shared) ---------- -DRIVER_EXCLUDES=() -[ "$SKIP_DELL10" = "1" ] && DRIVER_EXCLUDES+=(--exclude='Dell_10') -[ "$SKIP_LATITUDE" = "1" ] && DRIVER_EXCLUDES+=(--exclude='Latitude') +# ---------- winpeapps share (all image types + _shared) ---------- +WINPE_EXCLUDES=() +[ "$SKIP_OS" = "1" ] && WINPE_EXCLUDES+=(--exclude='_shared/gea-Operating Systems') +[ "$SKIP_DRIVERS" = "1" ] && WINPE_EXCLUDES+=(--exclude='_shared/Out-of-box Drivers' --exclude='Out-of-box Drivers') +[ "$SKIP_DELL10" = "1" ] && WINPE_EXCLUDES+=(--exclude='Dell_10') +[ "$SKIP_LATITUDE" = "1" ] && WINPE_EXCLUDES+=(--exclude='Latitude') -if [ "$SKIP_OS" = "0" ]; then - mirror "Operating Systems (gea-Operating Systems)" \ - "/srv/samba/winpeapps/_shared/gea-Operating Systems/" \ - "/srv/samba/winpeapps/_shared/gea-Operating Systems/" +mirror "winpeapps (all image types, _shared, tools)" \ + "/srv/samba/winpeapps/" \ + "/srv/samba/winpeapps/" \ + "${WINPE_EXCLUDES[@]}" + +# ---------- enrollment share (flat-layout root + taxonomy subdirs) ---------- +mirror "enrollment (taxonomy + flat-layout content)" \ + "/srv/samba/enrollment/" \ + "/srv/samba/enrollment/" + +# ---------- blancco-reports share (historical XML reports) ---------- +if [ "$SKIP_REPORTS" = "0" ]; then + mirror "blancco-reports (erasure report history)" \ + "/srv/samba/blancco-reports/" \ + "/srv/samba/blancco-reports/" fi -mirror "Packages (gea-Packages)" \ - "/srv/samba/winpeapps/_shared/gea-Packages/" \ - "/srv/samba/winpeapps/_shared/gea-Packages/" - -mirror "Sources (gea-Sources)" \ - "/srv/samba/winpeapps/_shared/gea-Sources/" \ - "/srv/samba/winpeapps/_shared/gea-Sources/" - -if [ "$SKIP_DRIVERS" = "0" ]; then - mirror "Out-of-box Drivers" \ - "/srv/samba/winpeapps/_shared/Out-of-box Drivers/" \ - "/srv/samba/winpeapps/_shared/Out-of-box Drivers/" \ - "${DRIVER_EXCLUDES[@]}" +# ---------- clonezilla share (disk backup images, can be very large) ---------- +if [ "$SKIP_CLONEZILLA" = "0" ]; then + mirror "clonezilla (disk backup images)" \ + "/srv/samba/clonezilla/" \ + "/srv/samba/clonezilla/" fi -# ---------- Per-image-type Deploy/ trees (mostly symlinks to _shared) ---------- -for img in gea-standard gea-engineer gea-shopfloor; do - mirror "Image dir: $img" \ - "/srv/samba/winpeapps/$img/" \ - "/srv/samba/winpeapps/$img/" \ - --exclude=backup --exclude=logs --exclude='New folder' --exclude=ProMax -done - -# ---------- Enrollment-share content (GOLD is on taxonomy layout, src == dest) ---------- - -# Pre-install: installers + bios + preinstall.json + udc-backups -mirror "Pre-install tree" \ - "/srv/samba/enrollment/pre-install/" \ - "/srv/samba/enrollment/pre-install/" \ - --exclude='*.old' - -# Post-install installers (CMM PC-DMIS, etc.) -mirror "Post-install installers" \ - "/srv/samba/enrollment/installers-post/" \ - "/srv/samba/enrollment/installers-post/" - -# Blancco custom image -mirror "Blancco images" \ - "/srv/samba/enrollment/blancco/" \ - "/srv/samba/enrollment/blancco/" - -# Site config (site-config.json, etc.) -mirror "Enrollment config" \ - "/srv/samba/enrollment/config/" \ - "/srv/samba/enrollment/config/" - -# Provisioning packages -mirror "PPKGs" \ - "/srv/samba/enrollment/ppkgs/" \ - "/srv/samba/enrollment/ppkgs/" - -# Enrollment scripts (run-enrollment.ps1, startnet.cmd, etc.) -mirror "Enrollment scripts" \ - "/srv/samba/enrollment/scripts/" \ - "/srv/samba/enrollment/scripts/" \ - --exclude='*.pre-*' - -# Shopfloor-setup tree (per-PC-type scripts + site-config) -mirror "Shopfloor-setup tree" \ - "/srv/samba/enrollment/shopfloor-setup/" \ - "/srv/samba/enrollment/shopfloor-setup/" - -# Permissions: anything we just created should be readable by the share -chown -R root:root /srv/samba/enrollment /srv/samba/winpeapps +# Permissions: make sure everything we pulled is readable by the share +chown -R root:root /srv/samba/enrollment /srv/samba/winpeapps /srv/samba/blancco-reports /srv/samba/clonezilla 2>/dev/null || true find /srv/samba/enrollment /srv/samba/winpeapps -type d -exec chmod 0755 {} \; 2>/dev/null || true find /srv/samba/enrollment /srv/samba/winpeapps -type f -exec chmod 0644 {} \; 2>/dev/null || true -find /srv/samba/enrollment/ppkgs -name '*.ppkg' -exec chmod 0755 {} \; 2>/dev/null || true +find /srv/samba/enrollment -name '*.ppkg' -exec chmod 0755 {} \; 2>/dev/null || true echo echo "============================================"