Files
lmxopcua/scripts/install/Invoke-OtOpcUaInstallSwap.ps1
T
Joseph Doherty a157b27628
v2-ci / unit-tests (push) Has been cancelled
fix(install): swap waits for the host process to exit before moving the folder (#908)
On QA (76e73e7d deploy) Invoke-OtOpcUaInstallSwap.ps1's first Move-Item of
app\ failed "file in use" right after the service reported Stopped: SCM
reports Stopped while the host is still exiting (e.g. a log file under
InstallRoot still open).

- After the stop, wait (bounded, -ProcessExitTimeoutSeconds, default 60)
  until the service's host process (PID + start time captured before the
  stop) and every process imaged under InstallRoot have exited; otherwise
  throw naming them, with nothing moved.
- The renames use [IO.Directory]::Move instead of Move-Item. On windev,
  PowerShell 7's Move-Item falls back to an item-by-item move when the
  rename fails because a file under the folder is open, and leaves a partial
  PreviousInstall tree behind (a retry then nests into it).
- The first rename is retried for at most -MoveRetrySeconds (default 10)
  while it fails because something under InstallRoot is open (a backstop
  for a holder whose image is outside InstallRoot).
- -Verify/-Reapply/-Scope semantics unchanged.

Tests: dry-run harness gains fake-process-list tests for the wait and the
retry plus AST order checks (145 passed); the Windows harness gains three
real cases (holder past the budget, holder released 2 s into the wait,
holder outside InstallRoot) - 77/0 on pwsh 7 and Windows PowerShell 5.1 on
windev; with the wait removed the first two fail.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 08:46:27 -05:00

270 lines
17 KiB
PowerShell

<#
.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. SCM can report Stopped while the
host process is still exiting (lmxopcua#908), so the script then waits, at most -ProcessExitTimeoutSeconds, until
the service's host process (PID captured before the stop) and every process whose image is under InstallRoot
(OtOpcUa.Host, the ScriptWorker) have exited. If one is still there, it throws naming them: nothing has moved.
3. Move InstallRoot -> PreviousInstall, then Candidate -> InstallRoot, each one rename ([IO.Directory]::Move; not
Move-Item, which in PowerShell 7 falls back to a partial item-by-item move when the rename fails). The first
move is retried for at most -MoveRetrySeconds while it fails because something under InstallRoot is open
(nothing has moved yet; a backstop for a handle the process wait cannot see).
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-<sha>; for a rollback
E:\ApiInstall\OtOpcUa.failed-<sha>.
.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 ProcessExitTimeoutSeconds
After the stop, how long to wait for the host process and any process running from InstallRoot to exit before
failing with nothing moved (default 60; lmxopcua#908).
.PARAMETER MoveRetrySeconds
How long to keep retrying the first folder move while it fails with a sharing violation (default 10; 0 = no retry).
.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-<sha> `
-PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.<date>-pre-<sha> -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,
[ValidateRange(0, 3600)] [int]$ProcessExitTimeoutSeconds = 60,
[ValidateRange(0, 600)] [int]$MoveRetrySeconds = 10
)
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 (the swap renames folders)'
}
$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 ---------------------------------------------------------------------------------------------------------
$hostPid = 0; $hostStart = $null
$window.Start()
if (-not $NoServiceControl) {
Step "stop $ServiceName"
$svc = Get-Service -Name $ServiceName
if ($svc.Status -ne 'Stopped') {
# lmxopcua#908: remember the host process (PID + start time, so a reused PID does not count) before stopping it.
$hostPid = [int](Get-CimInstance -ClassName Win32_Service -Filter "Name='$ServiceName'").ProcessId
if ($hostPid -gt 0) {
$hostProc = Get-Process -Id $hostPid -ErrorAction SilentlyContinue
if ($hostProc) { try { $hostStart = $hostProc.StartTime.ToUniversalTime() } catch { } }
}
Stop-Service -Name $ServiceName -Force
$svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds($StopTimeoutSeconds))
}
Mark 'stop' $window
}
# lmxopcua#908: SCM reports Stopped before the host process is gone; the folder move fails "in use" until it is.
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Step "wait for the host process (PID $(if ($hostPid) { $hostPid } else { 'n/a' })) and any process under $InstallRoot to exit (at most $ProcessExitTimeoutSeconds s)"
$waited = Wait-OtOpcUaInstallReleased -InstallRoot $InstallRoot -ProcessId $hostPid -ProcessStartTime $hostStart -TimeoutSeconds $ProcessExitTimeoutSeconds
Write-Host (' released after {0:N1} s' -f $waited)
Mark 'exit' $sw
# ---- 3. Swap ---------------------------------------------------------------------------------------------------------
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Step "move $InstallRoot -> $PreviousInstall"
# lmxopcua#908: [IO.Directory]::Move, not Move-Item. It is one rename (MoveFileEx, same volume: checked in preflight) that
# fails as a whole. PowerShell 7's Move-Item, when the rename fails because a file under the folder is open, falls back
# to moving item by item and leaves a partial PreviousInstall tree behind (windev), so a retry would then nest into it.
$attempts = Invoke-OtOpcUaMoveWithRetry -RetrySeconds $MoveRetrySeconds -Move { [System.IO.Directory]::Move($InstallRoot, $PreviousInstall) }
if ($attempts -gt 1) { Write-Host " moved on attempt $attempts" }
Step "move $Candidate -> $InstallRoot"
try { [System.IO.Directory]::Move($Candidate, $InstallRoot) }
catch {
Write-Warning "candidate move failed; putting the previous install back: $($_.Exception.Message)"
[System.IO.Directory]::Move($PreviousInstall, $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."