diff --git a/playbook/shopfloor-setup/gea-shopfloor-waxtrace/09-Setup-WaxAndTrace.ps1 b/playbook/shopfloor-setup/gea-shopfloor-waxtrace/09-Setup-WaxAndTrace.ps1 index cf76100..2b14217 100644 --- a/playbook/shopfloor-setup/gea-shopfloor-waxtrace/09-Setup-WaxAndTrace.ps1 +++ b/playbook/shopfloor-setup/gea-shopfloor-waxtrace/09-Setup-WaxAndTrace.ps1 @@ -329,27 +329,63 @@ if (-not $asset) { # does not exist on the freshly imaged bay). HKLM controller config / model # device-map / etc. came from the vendor MSI install in Step 2 already. $backupZip = $null -$backupDir = 'C:\WaxTrace-Install\backup' -if ($bayAsset -and (Test-Path -LiteralPath $backupDir)) { - $candidate = Join-Path $backupDir ("$bayAsset.zip") +# startnet.cmd robocopy's the whole installers-post\waxtrace\backups\ dir +# to C:\WaxTrace-Install\backups\ (plural). Try plural first, then singular +# for backward-compat with any older boot.wim that used the cherry-pick path. +$backupDirCandidates = @( + 'C:\WaxTrace-Install\backups', + 'C:\WaxTrace-Install\backup' +) +foreach ($bd in $backupDirCandidates) { + if (-not (Test-Path -LiteralPath $bd) -or -not $bayAsset) { continue } + $candidate = Join-Path $bd ("$bayAsset.zip") if (Test-Path -LiteralPath $candidate) { $backupZip = $candidate - } else { - $newest = Get-ChildItem -LiteralPath $backupDir -Filter "${bayAsset}*.zip" -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($newest) { $backupZip = $newest.FullName } + break + } + $newest = Get-ChildItem -LiteralPath $bd -Filter "${bayAsset}*.zip" -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($newest) { + $backupZip = $newest.FullName + break } } if ($backupZip) { $installPs1 = Join-Path $stagingRoot 'Install-FormtracepakSettings.ps1' if (Test-Path -LiteralPath $installPs1) { - Write-WTLog "Restoring per-asset backup from $backupZip" + Write-WTLog "Restoring per-asset backup from $backupZip (HKLM + files; HKEY_USERS deferred to first ShopFloor logon)" try { - & $installPs1 -BackupPath $backupZip -RestoreData -RestoreConfig -Force + # In-line restore: HKLM (controller config, device-map) + files + # (layouts, prefs in install dir). HKEY_USERS per-user prefs would + # need ShopFloor's hive loaded, which it isn't at imaging time - + # deferred to a scheduled SYSTEM task that fires on first ShopFloor + # logon (see Schedule-WaxTracePerUserRestore call below). + & $installPs1 -BackupPath $backupZip -RestoreRegistry -RestoreData -RestoreConfig -Force Write-WTLog " Restore call returned (see restore log + Install-FormtracepakSettings output above)" } catch { Write-WTLog " Restore call threw: $_" 'WARN' } + + # Schedule the per-user prefs restore as a SYSTEM task that fires on + # first ShopFloor logon. The task self-removes after one successful + # run via flag file at C:\WaxTrace-Install\per-user-restore-ShopFloor.flag. + $schedScript = Join-Path $stagingRoot 'Schedule-WaxTracePerUserRestore.ps1' + if (-not (Test-Path -LiteralPath $schedScript)) { + # Fall back: pull from shopfloor-setup scripts/ sibling tree + $alt = Join-Path $PSScriptRoot 'scripts\Schedule-WaxTracePerUserRestore.ps1' + if (Test-Path -LiteralPath $alt) { $schedScript = $alt } + } + if (Test-Path -LiteralPath $schedScript) { + Write-WTLog "Registering deferred ShopFloor per-user restore task ($schedScript)" + try { + & $schedScript -BackupPath $backupZip -InstallScript $installPs1 -TargetUser 'ShopFloor' -AssetNumber $bayAsset + Write-WTLog " Scheduled task 'WaxTrace-PerUser-Restore' registered" + } catch { + Write-WTLog " Schedule task call threw: $_" 'WARN' + } + } else { + Write-WTLog "Schedule-WaxTracePerUserRestore.ps1 not found - per-user prefs will NOT auto-restore at first ShopFloor logon" 'WARN' + } } else { Write-WTLog "Install-FormtracepakSettings.ps1 not found at $installPs1 - skipping restore" 'WARN' } diff --git a/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Install-FormtracepakSettings.ps1 b/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Install-FormtracepakSettings.ps1 index 409436c..61c9c89 100755 --- a/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Install-FormtracepakSettings.ps1 +++ b/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Install-FormtracepakSettings.ps1 @@ -57,7 +57,19 @@ param( [switch]$RestoreConfig, [switch]$RestoreAll, [switch]$DryRun, - [switch]$Force + [switch]$Force, + # Target user to remap captured HKEY_USERS\ per-user prefs onto. + # Captured backup carries the source bay's interactive user SID (e.g. + # lg782713sd at WJ); on the imaged bay that SID doesn't exist as a + # loaded hive. We look up $TargetUser's SID and rewrite the SID in + # both .reg files and the CSV restore path before importing, so prefs + # land under the new bay's user instead of an orphan SID. + [string]$TargetUser = 'ShopFloor', + # When true, skip HKLM .reg files + HKLM CSV rows. Used by the + # deferred scheduled task that runs on first ShopFloor login - HKLM + # was already restored during imaging, only HKEY_USERS prefs need + # to land in the now-loaded ShopFloor hive. + [switch]$HKEYUsersOnly ) $SharedRoot = 'S:\DT\Shopfloor\backup\waxandtrace' @@ -357,14 +369,126 @@ if ($RestoreRegistry) { $regDir = Join-Path $workingBackup 'registry' - # Method 1: .reg file import + # --- HKEY_USERS SID remap setup --- + # The captured HKEY_USERS\ entries belong to the source bay's + # interactive user (e.g. lg782713sd at WJ). On the imaged bay the + # equivalent user is whatever $TargetUser is (default: ShopFloor). + # Resolve target user's SID + ensure the hive is loaded so the rewrite + # lands somewhere real. If we can't resolve the target user, fall back + # to skipping the HKEY_USERS portion entirely (HKLM still restores). + $srcSidRegex = '(S-1-5-21-\d+-\d+-\d+-\d+)' + $targetSid = $null + $tempHiveLoaded = $false + # Resolve $TargetUser. If user gave an explicit value AND it resolves, + # use it. Otherwise try the fallback chain: ShopFloor (post-SFLD-2.0 + # default) -> SupportUser (imaging-context user) -> current $env:USERNAME. + $candidateUsers = @() + if ($TargetUser) { $candidateUsers += $TargetUser } + foreach ($u in @('ShopFloor','SupportUser', $env:USERNAME)) { + if ($u -and ($candidateUsers -notcontains $u)) { $candidateUsers += $u } + } + foreach ($cand in $candidateUsers) { + try { + $u = Get-LocalUser -Name $cand -ErrorAction Stop + $targetSid = $u.SID.Value + $TargetUser = $cand + Write-Host " Target user '$TargetUser' SID = $targetSid (Get-LocalUser)" + break + } catch { } + # Fall back: scan HKLM ProfileList for ProfileImagePath matching + # C:\Users\. Works for both local and domain users. + $profileList = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $sidFromProfile = $null + Get-ChildItem $profileList -ErrorAction SilentlyContinue | ForEach-Object { + $p = (Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue).ProfileImagePath + if ($p -and (Split-Path -Leaf $p) -ieq $cand) { + $sidFromProfile = $_.PSChildName + } + } + if ($sidFromProfile) { + $targetSid = $sidFromProfile + $TargetUser = $cand + Write-Host " Target user '$TargetUser' SID = $targetSid (ProfileList)" + break + } + } + if (-not $targetSid) { + Write-Warning " Could not resolve any of: $($candidateUsers -join ', ') - per-user prefs will be SKIPPED" + } + if (-not $targetSid) { + Write-Warning " Could not resolve '$TargetUser' SID - per-user prefs will be SKIPPED (only HKLM restores)" + } else { + # Ensure the target hive is loaded so reg.exe import + New-Item paths + # against HKEY_USERS\\... resolve. + if (-not (Test-Path "Registry::HKEY_USERS\$targetSid")) { + $ntuser = "C:\Users\$TargetUser\NTUSER.DAT" + if (Test-Path -LiteralPath $ntuser) { + Write-Host " Loading $TargetUser hive from $ntuser as HKU\$targetSid" + $proc = Start-Process -FilePath 'reg.exe' -ArgumentList "load `"HKU\$targetSid`" `"$ntuser`"" ` + -Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue + if ($proc.ExitCode -eq 0) { + $tempHiveLoaded = $true + } else { + Write-Warning " Failed to load $TargetUser hive (exit $($proc.ExitCode)) - per-user prefs will be SKIPPED" + $targetSid = $null + } + } else { + Write-Warning " $ntuser not found and HKU\$targetSid not loaded - per-user prefs will be SKIPPED" + $targetSid = $null + } + } + } + + # Method 1: .reg file import. If target SID resolved, rewrite the + # source SID in the .reg content to the target SID before importing. + # If not resolved, skip HKEY_USERS .reg files. $regFiles = Get-ChildItem -Path $regDir -Filter '*.reg' -ErrorAction SilentlyContinue foreach ($rf in $regFiles) { + $isUserReg = ($rf.Name -match 'HKEY_USERS') + if ($HKEYUsersOnly -and -not $isUserReg) { + Write-Host " [SKIP] HKEY_USERS-only mode, skipping HKLM .reg: $($rf.Name)" -ForegroundColor DarkGray + $counters.Skipped++ + continue + } + if ($isUserReg -and -not $targetSid) { + Write-Host " [SKIP] HKEY_USERS .reg file - target SID not resolved: $($rf.Name)" -ForegroundColor DarkGray + $counters.Skipped++ + continue + } + + $importPath = $rf.FullName + if ($isUserReg) { + # Read .reg, detect src SID (first S-1-5-21-* match), substitute + # target SID, write to a temp file. reg.exe import the temp. + try { + $content = Get-Content -LiteralPath $rf.FullName -Raw + $m = [regex]::Match($content, $srcSidRegex) + if ($m.Success) { + $srcSid = $m.Value + if ($srcSid -ne $targetSid) { + $newContent = $content -replace [regex]::Escape($srcSid), $targetSid + $tmp = Join-Path $env:TEMP ("rewrite-" + $rf.Name) + # .reg files MUST be UTF-16-LE (Unicode) for reg.exe import. + # Set-Content -Encoding Unicode does that. + Set-Content -LiteralPath $tmp -Value $newContent -Encoding Unicode -Force + $importPath = $tmp + Write-Host " [REMAP] $($rf.Name): SID $srcSid -> $targetSid" + } + } else { + Write-Host " [INFO] No SID found in $($rf.Name) - importing as-is" -ForegroundColor DarkGray + } + } catch { + Write-Warning " [ERR] SID rewrite failed for $($rf.Name): $_ - skipping" + $counters.Errors++ + continue + } + } + if ($DryRun) { - Write-Host " [DRYRUN] REG IMPORT $($rf.FullName)" -ForegroundColor DarkGray + Write-Host " [DRYRUN] REG IMPORT $importPath" -ForegroundColor DarkGray } else { try { - $proc = Start-Process -FilePath 'reg.exe' -ArgumentList "import `"$($rf.FullName)`"" ` + $proc = Start-Process -FilePath 'reg.exe' -ArgumentList "import `"$importPath`"" ` -Wait -PassThru -NoNewWindow -ErrorAction Stop if ($proc.ExitCode -eq 0) { Write-Host " [OK] Imported $($rf.Name)" -ForegroundColor Green @@ -402,6 +526,33 @@ if ($RestoreRegistry) { continue } + # HKEYUsersOnly mode: skip HKLM rows entirely (already restored + # during imaging by the in-line Step 3b run). + if ($HKEYUsersOnly -and $regPath -notmatch 'HKEY_USERS\\') { + $counters.Skipped++ + continue + } + + # HKEY_USERS\ rows: rewrite SID to target user's SID + # so per-user prefs from the source bay's interactive user land + # under the new bay's ShopFloor (or whichever $TargetUser is). + # If target SID couldn't be resolved, skip the row instead of + # erroring on the orphan path. + if ($regPath -match 'HKEY_USERS\\') { + if (-not $targetSid) { + Write-Host " [SKIP] HKEY_USERS row, no target SID: $regPath\$regName" -ForegroundColor DarkGray + $counters.Skipped++ + continue + } + $srcMatch = [regex]::Match($regPath, $srcSidRegex) + if ($srcMatch.Success) { + $srcSid = $srcMatch.Value + if ($srcSid -ne $targetSid) { + $regPath = $regPath -replace [regex]::Escape($srcSid), $targetSid + } + } + } + if ($regPath -match '^HKLM' -and -not $isAdmin -and -not $DryRun) { Write-Host " [SKIP] HKLM key requires elevation: $regPath\$regName" -ForegroundColor DarkYellow $counters.Skipped++ @@ -433,6 +584,23 @@ if ($RestoreRegistry) { $counters.RegistryKeys++ } } + + # Unload the target user's hive if we loaded it ourselves. Otherwise + # leave it alone (might be the live logged-in user). If we don't unload + # what we loaded, NTUSER.DAT stays locked and the user can't log in + # cleanly until reboot. + if ($tempHiveLoaded -and $targetSid) { + Write-Host " Unloading temporary $TargetUser hive at HKU\$targetSid" + # Force a GC pass to release any registry handles PowerShell is + # holding so reg unload doesn't fail with "access denied". + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + $proc = Start-Process -FilePath 'reg.exe' -ArgumentList "unload `"HKU\$targetSid`"" ` + -Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue + if ($proc.ExitCode -ne 0) { + Write-Warning " reg.exe unload returned exit $($proc.ExitCode) - $TargetUser NTUSER.DAT may stay locked until reboot" + } + } } # ---------------------------------------------------------------------- Summary ---------------------------------------------------------------------- diff --git a/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Schedule-WaxTracePerUserRestore.ps1 b/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Schedule-WaxTracePerUserRestore.ps1 new file mode 100644 index 0000000..cc2bfb7 --- /dev/null +++ b/playbook/shopfloor-setup/gea-shopfloor-waxtrace/scripts/Schedule-WaxTracePerUserRestore.ps1 @@ -0,0 +1,134 @@ +# Schedule-WaxTracePerUserRestore.ps1 +# +# Registers a SYSTEM-context scheduled task that fires on $TargetUser's +# first logon and restores HKEY_USERS per-user FormTracePak prefs from +# the per-asset backup ZIP into the now-loaded target user's hive. +# +# Why scheduled task + SYSTEM: +# At imaging time, the target user (ShopFloor) hasn't logged in yet so +# their NTUSER.DAT doesn't exist - can't reg-load their hive. By +# deferring to first-logon, the hive is loaded automatically by Windows. +# Running as SYSTEM means the task is not subject to lockdown policies +# applied to ShopFloor's user-context tokens. +# +# The task self-removes after one successful run (via a flag file + +# Unregister-ScheduledTask in the task action). If the restore errors, +# the flag is NOT written so the task retries on next logon. +# +# Usage (from 09-Setup-WaxAndTrace Step 3b after the in-line HKLM restore): +# & .\Schedule-WaxTracePerUserRestore.ps1 ` +# -BackupPath C:\WaxTrace-Install\backup\.zip ` +# -InstallScript C:\WaxTrace-Install\Install-FormtracepakSettings.ps1 ` +# -TargetUser ShopFloor ` +# -AssetNumber + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$BackupPath, + [string]$InstallScript = 'C:\WaxTrace-Install\Install-FormtracepakSettings.ps1', + [string]$TargetUser = 'ShopFloor', + [string]$AssetNumber = '', + [string]$RunnerScript = 'C:\WaxTrace-Install\Run-WaxTracePerUserRestore.ps1', + [string]$TaskName = 'WaxTrace-PerUser-Restore' +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $BackupPath)) { + Write-Warning "Backup ZIP not found at $BackupPath - skipping per-user restore task registration." + exit 1 +} +if (-not (Test-Path -LiteralPath $InstallScript)) { + Write-Warning "Install script not found at $InstallScript - skipping per-user restore task registration." + exit 1 +} + +# Drop the task-runner script alongside Install. It's small + self-contained. +# Reads the same params we register the task with, invokes Install with +# -HKEYUsersOnly + writes flag + unregisters self. +$runnerDir = Split-Path -Parent $RunnerScript +if (-not (Test-Path -LiteralPath $runnerDir)) { + New-Item -ItemType Directory -Path $runnerDir -Force | Out-Null +} + +@" +# Run-WaxTracePerUserRestore.ps1 - task action, runs as SYSTEM at logon +# Auto-generated by Schedule-WaxTracePerUserRestore.ps1; do not hand-edit. + +param( + [string]`$BackupPath = '$BackupPath', + [string]`$InstallScript = '$InstallScript', + [string]`$TargetUser = '$TargetUser', + [string]`$AssetNumber = '$AssetNumber', + [string]`$TaskName = '$TaskName', + [string]`$FlagFile = 'C:\WaxTrace-Install\per-user-restore-$TargetUser.flag' +) + +`$ErrorActionPreference = 'Continue' +`$logDir = 'C:\Logs\WaxTrace' +if (-not (Test-Path `$logDir)) { New-Item -ItemType Directory -Path `$logDir -Force | Out-Null } +`$log = Join-Path `$logDir ('per-user-restore-' + `$TargetUser + '-' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.log') +Start-Transcript -LiteralPath `$log -Append -IncludeInvocationHeader | Out-Null + +try { + Write-Host "=== Per-user restore task ===" + Write-Host " TargetUser = `$TargetUser" + Write-Host " AssetNumber = `$AssetNumber" + Write-Host " BackupPath = `$BackupPath" + Write-Host " InstallScript = `$InstallScript" + Write-Host " FlagFile = `$FlagFile" + Write-Host " RunningAs = `$(`$env:USERNAME) ([Security.Principal.WindowsIdentity]::GetCurrent().Name)" + + if (Test-Path -LiteralPath `$FlagFile) { + Write-Host "Flag file present - already complete, exiting." + exit 0 + } + + # Give Windows a moment to finish loading the user hive after logon. + Start-Sleep -Seconds 8 + + if (-not (Test-Path -LiteralPath `$BackupPath)) { Write-Warning "Backup ZIP missing: `$BackupPath"; exit 2 } + if (-not (Test-Path -LiteralPath `$InstallScript)) { Write-Warning "Install script missing: `$InstallScript"; exit 2 } + + & `$InstallScript -BackupPath `$BackupPath -RestoreRegistry -HKEYUsersOnly -Force -TargetUser `$TargetUser + `$rc = `$LASTEXITCODE + if (`$null -eq `$rc) { `$rc = 0 } + + if (`$rc -eq 0) { + Set-Content -LiteralPath `$FlagFile -Value (Get-Date -Format 'o') -Encoding ascii -Force + Write-Host "Restore complete - flag file written. Unregistering task '`$TaskName'." + Unregister-ScheduledTask -TaskName `$TaskName -Confirm:`$false -ErrorAction SilentlyContinue + } else { + Write-Warning "Restore returned exit code `$rc - leaving task registered for retry on next logon." + } + exit `$rc +} catch { + Write-Warning "Task action threw: `$_" + exit 99 +} finally { + Stop-Transcript | Out-Null +} +"@ | Set-Content -LiteralPath $RunnerScript -Encoding ascii -Force +Write-Host "Wrote task action script: $RunnerScript" + +# Register the task. AtLogOn trigger filtered to $TargetUser so it only fires +# when that user logs on (other users logging on don't trigger). Principal +# SYSTEM, RunLevel Highest = full privileges including HKLM writes that +# survive lockdown policies on ShopFloor's user-context. +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$RunnerScript`"" +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $TargetUser +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30) + +# Unregister any existing instance first so re-runs are clean. +if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false +} + +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "FormTracePak per-user prefs restore for $TargetUser (one-shot at first logon, then self-removes). Asset $AssetNumber." | Out-Null + +Write-Host "Registered scheduled task '$TaskName':" +Write-Host " Trigger: AtLogOn UserId=$TargetUser" +Write-Host " Principal: SYSTEM (RunLevel Highest)" +Write-Host " Action: $RunnerScript" +Write-Host " Self-removes after one successful restore." diff --git a/playbook/sync-waxtrace.sh b/playbook/sync-waxtrace.sh index e86b25f..dfdb7f7 100755 --- a/playbook/sync-waxtrace.sh +++ b/playbook/sync-waxtrace.sh @@ -81,6 +81,14 @@ fi if [ -f "$WAXTRACE_DIR/scripts/Install-FormtracepakSettings.ps1" ]; then cp "$WAXTRACE_DIR/scripts/Install-FormtracepakSettings.ps1" "$STAGE/" fi +# Schedule-WaxTracePerUserRestore.ps1 - registers a SYSTEM scheduled task +# that fires on first ShopFloor logon to restore HKEY_USERS per-user prefs +# (deferred from imaging time because ShopFloor's hive isn't loaded yet). +# Path on the bay post-startnet-robocopy: +# C:\WaxTrace-Install\Schedule-WaxTracePerUserRestore.ps1. +if [ -f "$WAXTRACE_DIR/scripts/Schedule-WaxTracePerUserRestore.ps1" ]; then + cp "$WAXTRACE_DIR/scripts/Schedule-WaxTracePerUserRestore.ps1" "$STAGE/" +fi cp "$WAXTRACE_DIR/captured-binary/prereqs/"*.exe "$STAGE/prereqs/" # FormTracePak vendor installer ISOs - all available versions get pushed.