<# .SYNOPSIS Swaps an OtOpcUa install folder for a candidate build and restores the ScriptWorker isolation before the host starts again (lmxopcua#901). Also the rollback: swap the previous install back in the same way. .DESCRIPTION A folder swap drops the explicit worker-isolation ACEs on script-worker\, on the install folder and on any denied folder that is recreated or copied in (pki\ on vd03), because a moved-in folder carries its own DACL. This script makes the re-apply part of the swap, in this order: 0. Preflight (nothing changed yet): the candidate exists and carries script-worker\; PreviousInstall does not exist and is on the same volume; when isolation is installed, its settings are recorded (an install by an installer older than #901 recorded only DenyPaths; then run the full-argument install once first, or pass -SkipIsolation). 1. Isolation, when recorded, with the old build still serving (lmxopcua#905): Install-ScriptWorkerIsolation.ps1 -Reapply -Scope Outside, then -Verify -Scope Outside: every step the swap cannot undo (anchor service, records, PE cache, denies outside InstallRoot, firewall rule, host env) and the coverage scan of the denied folders outside InstallRoot. If either fails, the script stops here: nothing is stopped or moved. 2. Stop the service (unless -NoServiceControl). The stopped window starts here. 3. Move InstallRoot -> PreviousInstall, then Candidate -> InstallRoot. 4. Copy each -Carry item (relative to InstallRoot; wildcards allowed) from PreviousInstall into the new InstallRoot. Folders are copied with robocopy /E /COPY:DAT (data, attributes, timestamps; not ACLs: step 5 sets those). 5. Isolation, when recorded: -Reapply -Scope InstallRoot, then -Verify -Scope InstallRoot: only the entries a swap drops (the ACEs on InstallRoot, script-worker\ and a denied folder inside InstallRoot such as pki\) and the coverage scan of InstallRoot. Steps 1 and 5 together check every install step (the firewall rule names the worker exe by its unchanged path; preflight checks the candidate carries that exe), so the new build is never started unverified. If -Verify fails, the script stops here with the service STOPPED (fail closed): fix it, run -Reapply/-Verify (no -Scope), then start the service. 6. Start the service (unless -NoStart, e.g. a Primary/Secondary pair window where both nodes start together). Prints the stopped window (stop issued -> start issued) and the time of each step in it. lmxopcua#905: the coverage scan used to run over every denied folder in both -Reapply and -Verify while the service was stopped, with Get-Acl per item; on vd03 (E:\ApiInstall denied) that was most of a 101 s outage. It is now about 20x faster per item and, inside the stopped window, walks only the new InstallRoot. Not covered (layout-specific; keep them in the deploy's own scripts): backups of data\ / the registry / the ConfigDb, restoring data\ on a rollback, migrations, health checks. .PARAMETER InstallRoot The live install folder (OtOpcUa.Host.exe + script-worker\). vd03: E:\ApiInstall\OtOpcUa. QA: E:\ApiInstall\OtOpcUa\app. .PARAMETER Candidate The expanded new build (for a rollback: the previous install). .PARAMETER PreviousInstall Where the live folder is moved to. Must not exist. E.g. E:\ApiInstall\OtOpcUa.bak.20261113-pre-; for a rollback E:\ApiInstall\OtOpcUa.failed-. .PARAMETER Carry Items copied from the previous install into the new one, relative to InstallRoot. Default: appsettings*.json, pki, logs (the vd03 layout, where pki\ and logs\ live inside the install folder). QA keeps pki\ and data\ beside app\: pass -Carry 'appsettings*.json','logs'. A rollback swaps back a complete folder: pass -NoCarry. .PARAMETER NoCarry Copy nothing from the previous install (a rollback). .PARAMETER ServiceName The host service (default OtOpcUaHost). .PARAMETER NoServiceControl Neither stop nor start the service (the caller owns it; used by the scratch test). .PARAMETER NoStart Leave the service stopped after a successful swap and verify. .PARAMETER SkipIsolation Do not re-apply/verify the isolation even when it is installed. WARNs. Only for a box where it is being removed. .PARAMETER WorkerServiceName Passed to the isolation installer (default OtOpcUaScriptWorker). .EXAMPLE # vd03 (pki\ and logs\ inside the install folder) .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.new-6c4d2990 ` -PreviousInstall E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990 .EXAMPLE # QA, one node of the stop-both/start-both window (start both afterwards) .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -Candidate E:\ApiInstall\OtOpcUa\app.new- ` -PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.-pre- -Carry 'appsettings*.json','logs' -NoStart .EXAMPLE # Rollback on vd03: the failed build out, the previous one back (restore data\ separately) .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990 ` -PreviousInstall E:\ApiInstall\OtOpcUa.failed-6c4d2990 -NoCarry #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$Candidate, [Parameter(Mandatory)] [string]$PreviousInstall, [string[]]$Carry = @('appsettings*.json', 'pki', 'logs'), [switch]$NoCarry, [string]$ServiceName = 'OtOpcUaHost', [switch]$NoServiceControl, [switch]$NoStart, [switch]$SkipIsolation, [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker', [int]$StopTimeoutSeconds = 120 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force $installer = Join-Path $PSScriptRoot 'Install-ScriptWorkerIsolation.ps1' function Step([string]$Message) { Write-Host "==> $Message" } function Invoke-Isolation([string]$Mode, [string]$Scope) { # Same PowerShell host as this script; the installer's exit code is the gate. $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -Scope $Scope -WorkerServiceName $WorkerServiceName | ForEach-Object { Write-Host " $_" } $LASTEXITCODE } # lmxopcua#905: the stopped window and what it is spent on. $window = [System.Diagnostics.Stopwatch]::new() $phases = [System.Collections.Generic.List[string]]::new() function Mark([string]$Name, [System.Diagnostics.Stopwatch]$Sw) { $phases.Add(('{0} {1:N1} s' -f $Name, $Sw.Elapsed.TotalSeconds)) } $InstallRoot = $InstallRoot.TrimEnd('\') $Candidate = $Candidate.TrimEnd('\') $PreviousInstall = $PreviousInstall.TrimEnd('\') if ($NoCarry) { $Carry = @() } $Carry = @($Carry | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) # ---- 0. Preflight: nothing changes until all of it passes ----------------------------------------------------------- $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run elevated' } if (-not (Test-Path -LiteralPath $InstallRoot -PathType Container)) { throw "InstallRoot $InstallRoot not found" } if (-not (Test-Path -LiteralPath (Join-Path $Candidate 'script-worker') -PathType Container)) { throw "candidate $Candidate has no script-worker\ (not a Host publish?)" } # lmxopcua#905: the firewall rule (verified before the stop) blocks this exe by path; the swapped-in folder must carry it. $candidateWorkerExe = Join-Path $Candidate 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' if (Test-Path -LiteralPath $PreviousInstall) { throw "PreviousInstall $PreviousInstall already exists; pick a fresh name" } if ([IO.Path]::GetPathRoot($InstallRoot) -ne [IO.Path]::GetPathRoot($PreviousInstall) -or [IO.Path]::GetPathRoot($InstallRoot) -ne [IO.Path]::GetPathRoot($Candidate)) { throw 'InstallRoot, Candidate and PreviousInstall must be on the same volume (Move-Item renames)' } $isolation = $null if (-not $SkipIsolation) { $isolation = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName $legacy = @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName) $anchor = Get-Service -Name $WorkerServiceName -ErrorAction SilentlyContinue if (-not $isolation -and ($anchor -or $legacy.Count)) { throw ("ScriptWorker isolation is installed but its settings are not recorded (installed by an installer older than lmxopcua#901). " + 'Before swapping, run Install-ScriptWorkerIsolation.ps1 once with its full arguments (the deploy pack lists them); that writes the record. ' + 'Then re-run this swap.') } if ($isolation -and $isolation.InstallRoot.TrimEnd('\') -ine $InstallRoot) { throw "the isolation record is for InstallRoot '$($isolation.InstallRoot)', not '$InstallRoot'; fix -InstallRoot or re-run the isolation install" } if ($isolation -and -not (Test-Path -LiteralPath $candidateWorkerExe -PathType Leaf)) { throw "candidate has no $candidateWorkerExe; the worker firewall rule blocks that path (a framework-dependent publish is not covered by it)" } if ($isolation) { Write-Host "Isolation installed (record: InstallRoot $($isolation.InstallRoot)); re-applied and verified outside InstallRoot before the stop, inside it after the swap." } else { Write-Host 'Isolation not installed on this node; nothing to re-apply.' } } else { Write-Warning '-SkipIsolation: the worker-isolation ACLs are NOT re-applied; a swapped folder has lost them (lmxopcua#901).' } if (-not $PSCmdlet.ShouldProcess($InstallRoot, "swap in $Candidate (previous install -> $PreviousInstall)")) { return } # ---- 1. Isolation outside InstallRoot, old build still serving (lmxopcua#905) --------------------------------------- if ($isolation) { $sw = [System.Diagnostics.Stopwatch]::StartNew() Step 'worker isolation outside the install folder: -Reapply -Scope Outside (service still running)' $code = Invoke-Isolation 'Reapply' 'Outside' if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Reapply -Scope Outside failed ($code); nothing stopped or swapped" } Step 'worker isolation outside the install folder: -Verify -Scope Outside (pre-stop gate)' $code = Invoke-Isolation 'Verify' 'Outside' if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Verify -Scope Outside failed ($code); nothing stopped or swapped. Fix the [MISSING] steps (-Reapply), then re-run the swap." } Write-Host (' (pre-stop isolation {0:N1} s, service still running)' -f $sw.Elapsed.TotalSeconds) } # ---- 2. Stop --------------------------------------------------------------------------------------------------------- $window.Start() if (-not $NoServiceControl) { Step "stop $ServiceName" $svc = Get-Service -Name $ServiceName if ($svc.Status -ne 'Stopped') { Stop-Service -Name $ServiceName -Force $svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds($StopTimeoutSeconds)) } Mark 'stop' $window } # ---- 3. Swap --------------------------------------------------------------------------------------------------------- $sw = [System.Diagnostics.Stopwatch]::StartNew() Step "move $InstallRoot -> $PreviousInstall" Move-Item -LiteralPath $InstallRoot -Destination $PreviousInstall Step "move $Candidate -> $InstallRoot" try { Move-Item -LiteralPath $Candidate -Destination $InstallRoot } catch { Write-Warning "candidate move failed; putting the previous install back: $($_.Exception.Message)" Move-Item -LiteralPath $PreviousInstall -Destination $InstallRoot throw } Mark 'move' $sw # ---- 4. Carry -------------------------------------------------------------------------------------------------------- $sw = [System.Diagnostics.Stopwatch]::StartNew() foreach ($item in $Carry) { # Get-Item, not Get-ChildItem: a folder name must give the folder itself, not its children (wildcards still expand). $sources = @(Get-Item -Path (Join-Path $PreviousInstall $item) -Force -ErrorAction SilentlyContinue) if ($sources.Count -eq 0) { Write-Host " (carry: nothing matches $item)"; continue } foreach ($src in $sources) { $dst = Join-Path $InstallRoot $src.Name if ($src.PSIsContainer) { & robocopy $src.FullName $dst /E /COPY:DAT /DCOPY:DAT /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null if ($LASTEXITCODE -ge 8) { throw "robocopy $($src.FullName) -> $dst failed ($LASTEXITCODE)" } } else { Copy-Item -LiteralPath $src.FullName -Destination $dst -Force } Write-Host " carried $($src.Name)" } } Mark 'carry' $sw # ---- 5. Isolation inside InstallRoot: re-apply + verify (fail closed) ------------------------------------------------ if ($isolation) { $sw = [System.Diagnostics.Stopwatch]::StartNew() Step 'worker isolation in the swapped-in folder: -Reapply -Scope InstallRoot' $code = Invoke-Isolation 'Reapply' 'InstallRoot' if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Reapply -Scope InstallRoot failed ($code); $ServiceName left stopped" } Step 'worker isolation in the swapped-in folder: -Verify -Scope InstallRoot (post-swap gate)' $code = Invoke-Isolation 'Verify' 'InstallRoot' if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Verify -Scope InstallRoot failed ($code); $ServiceName left stopped. Fix the [MISSING] steps, re-run -Reapply/-Verify, then start it." } Mark 'isolation' $sw } # ---- 6. Start -------------------------------------------------------------------------------------------------------- $window.Stop() $windowName = if ($NoServiceControl) { 'swap window (service control is the caller''s)' } elseif ($NoStart) { 'stop -> swapped and verified (left stopped)' } else { 'stopped window (stop issued -> start issued)' } Write-Host ('{0}: {1:N1} s [{2}]' -f $windowName, $window.Elapsed.TotalSeconds, ($phases -join ', ')) if (-not $NoServiceControl -and -not $NoStart) { Step "start $ServiceName" Start-Service -Name $ServiceName (Get-Service -Name $ServiceName).WaitForStatus('Running', [TimeSpan]::FromSeconds(60)) } Write-Host "Swap complete: $InstallRoot is the candidate; previous install at $PreviousInstall."