<# .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. Stop the service (unless -NoServiceControl). 2. Move InstallRoot -> PreviousInstall, then Candidate -> InstallRoot. 3. 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 4 sets those). 4. Isolation, when recorded: Install-ScriptWorkerIsolation.ps1 -Reapply, then -Verify. If -Verify fails, the script stops here with the service STOPPED (fail closed): fix it, re-run -Reapply/-Verify, then start the service. 5. Start the service (unless -NoStart, e.g. a Primary/Secondary pair window where both nodes start together). 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) { # 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" -WorkerServiceName $WorkerServiceName | ForEach-Object { Write-Host " $_" } $LASTEXITCODE } $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?)" } 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) { Write-Host "Isolation installed (record: InstallRoot $($isolation.InstallRoot)); it is re-applied and verified 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. Stop --------------------------------------------------------------------------------------------------------- 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)) } } # ---- 2. Swap --------------------------------------------------------------------------------------------------------- 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 } # ---- 3. Carry -------------------------------------------------------------------------------------------------------- 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)" } } # ---- 4. Isolation re-apply + verify (fail closed) -------------------------------------------------------------------- if ($isolation) { Step 'worker isolation: -Reapply' $code = Invoke-Isolation 'Reapply' if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Reapply failed ($code); $ServiceName left stopped" } Step 'worker isolation: -Verify (post-swap gate)' $code = Invoke-Isolation 'Verify' if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Verify failed ($code); $ServiceName left stopped. Fix the [MISSING] steps, re-run -Reapply/-Verify, then start it." } } # ---- 5. Start -------------------------------------------------------------------------------------------------------- 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."