Files
lmxopcua/scripts/install/Invoke-OtOpcUaInstallSwap.ps1
T
Joseph Doherty 2cb8a2c317
v2-ci / unit-tests (push) Has been cancelled
fix(install): worker isolation survives a folder swap: recorded settings, -Reapply, -Verify, swap script (#901)
An upgrade that swaps the install folder dropped the explicit ScriptWorker isolation ACEs on script-worker\, the
install folder and a copied-back pki\ (vd03 2026-11-13). Re-applying needed the operator to repeat the arguments.

- Install-ScriptWorkerIsolation.ps1 records its effective settings (InstallRoot, HostServiceAccount, PeCacheDir,
  HostServiceName, ConfigureHostService) beside DenyPaths under ...\Services\OtOpcUaScriptWorker\Parameters (new plan
  step SettingsRecord) and locks that key to SYSTEM + Administrators (protected DACL; no secrets recorded).
- -Reapply: re-apply from the record, no arguments; prints a summary line (changed/unchanged).
- -Verify: check only, [ok]/[MISSING] per step, exit 1 when anything is missing. -Uninstall also works from the record.
  A node isolated by a pre-#901 installer (DenyPaths only) must run the full-argument install once; both modes say so.
- Invoke-OtOpcUaInstallSwap.ps1: preflight -> stop -> move live/candidate -> carry appsettings/pki/logs ->
  -Reapply -> -Verify -> start; a failed -Verify leaves the service stopped. Also the rollback (-NoCarry).
- Tests: dry-run harness 101/101 (record + argument rules; record-key SDDL mutation turns 2 red). The Windows test adds
  swap -> Verify fails (exactly the 3 lost ACEs) -> Reapply (3 changed) -> Verify ok -> Reapply 12/12 unchanged, PE-cache
  and firewall negative controls, the swap script, and the pre-#901 refusal; 52/52 on windev under 5.1 and 7, with a
  scratch local host account and with SYSTEM. Evidence: scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify.
- Docs: ServiceHosting isolation section; vd03 pack (swap step, rollback, post-deploy gate, one-time full-argument run),
  the 2026-11-13 vd03 record, and the QA pack (QA swap preceded the isolation install; ACLs checked intact over SMB on
  vq03/vq04 2026-11-13; next-deploy procedure).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 02:06:10 -05:00

190 lines
11 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. 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-<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 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
)
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."