a157b27628
v2-ci / unit-tests (push) Has been cancelled
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
492 lines
44 KiB
PowerShell
492 lines
44 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter
|
|
-DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged ->
|
|
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> -Scope InstallRoot/Outside
|
|
after a swap, the swap's pre-stop gate, -NoStart, a -NoCarry rollback (lmxopcua#905) -> a process still
|
|
running from InstallRoot after the stop: waited for, timed out clearly, and an unseen handle retried (lmxopcua#908) -> uninstall (from the record,
|
|
without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
|
|
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
|
|
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
|
|
DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1.
|
|
Run it once under EACH host:
|
|
|
|
pwsh -NoProfile -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
|
|
|
|
lmxopcua#905 timing: -TimingFiles N also builds a vd03-like tree (a denied ApiInstall folder holding the install, a
|
|
candidate and N more files in other app folders, 48 protected-DACL files), installs the isolation on it and times
|
|
Invoke-OtOpcUaInstallSwap.ps1. -TimingOnly skips the functional part. -ScriptsDir runs the timing against another
|
|
copy of scripts\install (e.g. the scripts before #905) for a before/after:
|
|
|
|
pwsh -NoProfile -File ...\Test-ScriptWorkerIsolation.Windows.ps1 -TimingOnly -TimingFiles 17000 -ScriptsDir C:\old\install
|
|
|
|
.DESCRIPTION
|
|
Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a
|
|
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM',
|
|
or with -LocalHostAccount a scratch local user (random password, never shown) that is deleted at the end.
|
|
It does NOT touch OtOpcUaHost. The firewall rule name is fixed by the installer, so the test refuses to run (exit 2) when
|
|
that rule already exists, i.e. on a node that has the real isolation installed. Everything it creates is removed in
|
|
`finally`, including on failure.
|
|
#>
|
|
[CmdletBinding()]
|
|
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount,
|
|
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct', [int]$TimingFiles = 0, [switch]$TimingOnly, [string]$ScriptsDir)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Write-Host 'SKIP: Windows only'; exit 0 }
|
|
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host 'run elevated'; exit 2 }
|
|
|
|
$installer = Join-Path (Split-Path -Parent $PSScriptRoot) 'Install-ScriptWorkerIsolation.ps1'
|
|
if (-not $ScriptsDir) { $ScriptsDir = Split-Path -Parent $PSScriptRoot }
|
|
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
|
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'ScriptWorkerIsolation.psm1') -Force
|
|
$ruleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
|
|
if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-Host "refusing: firewall rule $ruleName already exists (real isolation installed on this node)"; exit 2 }
|
|
foreach ($n in $WorkerServiceName, $HostServiceName) {
|
|
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { Write-Host "refusing: service $n already exists"; exit 2 }
|
|
}
|
|
if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Write-Host "refusing: local user $LocalHostAccountName already exists"; exit 2 }
|
|
$script:hostAccount = 'NT AUTHORITY\SYSTEM'
|
|
|
|
$script:failures = 0; $script:passes = 0
|
|
function Assert([bool]$Condition, [string]$Message) {
|
|
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
|
|
}
|
|
function Invoke-Installer([string[]]$Extra) {
|
|
# $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits.
|
|
# Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'.
|
|
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
|
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount $script:hostAccount `
|
|
-PeCacheDir $cache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService @Extra 2>&1 | Out-String
|
|
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } })
|
|
[pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps }
|
|
}
|
|
function Invoke-Mode([string]$Mode, [string[]]$Extra = @()) {
|
|
# lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector).
|
|
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
|
# Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is
|
|
# collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width.
|
|
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName @Extra 2>&1 | Out-String }
|
|
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } })
|
|
$code = $LASTEXITCODE
|
|
$missing = @([regex]::Matches($out, '\[MISSING\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" })
|
|
[pscustomobject]@{ Exit = $code; Output = $out; Flat = ($out -replace '[\s|]+', ' '); Steps = $steps; Missing = $missing }
|
|
}
|
|
function Show([string]$Label, $R) {
|
|
# The transcript the issue asks for: one line per run.
|
|
$what = if ($R.Missing.Count) { "MISSING: $($R.Missing -join ' | ')" } else { ($R.Steps | Group-Object Outcome | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' }
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f $Label, $R.Exit, $what)
|
|
}
|
|
function Get-Explicit([string]$Path, [string]$Sid) {
|
|
$id = [System.Security.Principal.SecurityIdentifier]::new($Sid)
|
|
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $id })
|
|
}
|
|
|
|
$scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
$root = Join-Path $scratch 'install'
|
|
$cache = Join-Path $scratch 'pe-cache'
|
|
# lmxopcua#890: the deployment folder + keys + secrets. pki\ inside the install root, as on vd03 (lmxopcua#901: the swap re-creates it).
|
|
$deny = @($scratch, (Join-Path $root 'pki'), (Join-Path $scratch 'data'))
|
|
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
|
|
$workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
|
|
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
|
|
$hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName"
|
|
try {
|
|
New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null
|
|
Set-Content -LiteralPath $workerExe -Value 'stub'
|
|
foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
|
if ($LocalHostAccount) {
|
|
$pw = [Security.SecureString]::new()
|
|
foreach ($ch in ([Convert]::ToBase64String((1..24 | ForEach-Object { [byte](Get-Random -Maximum 256) })) + 'aA1!').ToCharArray()) { $pw.AppendChar($ch) }
|
|
New-LocalUser -Name $LocalHostAccountName -Password $pw -AccountNeverExpires -Description 'lmxopcua#901 isolation test (scratch)' | Out-Null
|
|
$script:hostAccount = ".\$LocalHostAccountName"
|
|
}
|
|
Set-Content -LiteralPath (Join-Path $root 'appsettings.json') -Value '{}'
|
|
& sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" }
|
|
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
|
|
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount"
|
|
|
|
if (-not $TimingOnly) {
|
|
Write-Host 'Dry run'
|
|
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
|
|
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
|
|
Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and
|
|
-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing'
|
|
|
|
Write-Host 'Apply'
|
|
$a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
|
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
|
|
Assert ($a.Steps.Count -eq 12 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 12 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
|
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled'
|
|
Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid'
|
|
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)'
|
|
$pe = @(Get-Explicit (Join-Path $cache 'pe') $workerSid)
|
|
Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on <PeCacheDir>\pe'
|
|
Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root'
|
|
Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected'
|
|
$rootDeny = @(Get-Explicit $root $workerSid)
|
|
Assert ($rootDeny.Count -eq 1 -and $rootDeny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
|
|
Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\'
|
|
foreach ($d in $deny) {
|
|
$dd = @(Get-Explicit $d $workerSid)
|
|
Assert ($dd.Count -eq 1 -and $dd[0].AccessControlType -eq 'Deny' -and $dd[0].InheritanceFlags -eq 'ContainerInherit, ObjectInherit') "worker has one inherited Deny ACE on $d"
|
|
}
|
|
Assert (((Get-ItemProperty -Path $recordKey -Name DenyPaths).DenyPaths -join '|') -eq ($deny -join '|')) 'the denied folders are recorded under the anchor key'
|
|
$hostDeny = @((Get-Acl -LiteralPath $scratch).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
|
|
Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference.Value -ne $workerSid })
|
|
Assert ($hostDeny.Count -eq 0) 'no deny for anyone but the worker identity (the host account is unaffected)'
|
|
$fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
|
|
Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe'
|
|
$env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
|
|
Assert (($env1 -contains 'Keep__Me=1') -and (@($env1 | Where-Object { $_ -like 'Scripting__Worker__Identity=*' }).Count -eq 1) -and
|
|
(@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others'
|
|
|
|
Write-Host 'Re-apply (idempotent)'
|
|
$b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
|
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
|
|
Assert ($b.Steps.Count -eq 12 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 12 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
|
|
|
Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay'
|
|
$c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ','))
|
|
Assert ($c.Exit -eq 0) "shorter re-apply exits 0 ($($c.Output))"
|
|
$changedIds = @($c.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ','
|
|
Assert ($changedIds -eq 'SensitiveDirDeny,DenyPathRecord') "only the stale deny and the record change ($changedIds)"
|
|
Assert (@(Get-Explicit $deny[2] $workerSid).Count -eq 0 -and @(Get-Explicit $deny[1] $workerSid).Count -eq 1) 'data\ un-denied, pki\ still denied'
|
|
$c2 = Invoke-Installer @('-DenyPaths', ($deny -join ','))
|
|
Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only'
|
|
|
|
Write-Host 'Settings record (lmxopcua#901)'
|
|
$recVals = Get-ItemProperty -Path $recordKey
|
|
Assert ($recVals.InstallRoot -eq $root -and $recVals.HostServiceAccount -eq $script:hostAccount -and $recVals.PeCacheDir -eq $cache -and
|
|
$recVals.HostServiceName -eq $HostServiceName -and $recVals.ConfigureHostService -eq 1) 'install root, host account, PE cache, host service and the flag are recorded'
|
|
Assert (Test-OtOpcUaRecordKeyAcl -Key $recordKey) "record key DACL is protected SYSTEM + Administrators ($((Get-Acl -Path $recordKey).GetSecurityDescriptorSddlForm('Access')))"
|
|
$settings = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName
|
|
Assert ($null -ne $settings -and (@($settings.DenyPaths) -join '|') -eq ($deny -join '|') -and $settings.ConfigureHostService) 'Get-OtOpcUaIsolationSettings reads it back'
|
|
|
|
Write-Host 'Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)'
|
|
$v0 = Invoke-Mode 'Verify'; Show 'Verify (installed)' $v0
|
|
Assert ($v0.Exit -eq 0 -and $v0.Output -match 'VERIFY OK') "verify passes on a fresh install ($($v0.Output))"
|
|
# The swap as the deploy packs did it: build the candidate beside the live folder, move live aside, move candidate in,
|
|
# copy pki\ and appsettings back (robocopy without ACLs, as a new folder).
|
|
$cand = Join-Path $scratch 'install.new'
|
|
New-Item -ItemType Directory -Path (Join-Path $cand 'script-worker') -Force | Out-Null
|
|
Set-Content -LiteralPath (Join-Path $cand 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v2'
|
|
$prev1 = Join-Path $scratch 'install.bak.1'
|
|
Move-Item -LiteralPath $root -Destination $prev1
|
|
Move-Item -LiteralPath $cand -Destination $root
|
|
& robocopy (Join-Path $prev1 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
|
|
Copy-Item -LiteralPath (Join-Path $prev1 'appsettings.json') -Destination $root
|
|
$v1 = Invoke-Mode 'Verify'; Show 'Verify (after the swap)' $v1
|
|
Assert ($v1.Exit -eq 1 -and $v1.Output -match 'VERIFY FAILED') "verify exits 1 after the swap ($($v1.Exit))"
|
|
$expectMissing = @("WorkerFolderAcl $root\script-worker", "InstallRootDeny $root", "SensitiveDirDeny $root\pki")
|
|
Assert ((@($v1.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "exactly the three folder ACEs the swap drops are MISSING ($($v1.Missing -join ' | '))"
|
|
$r1 = Invoke-Mode 'Reapply'; Show 'Reapply (no arguments)' $r1
|
|
Assert ($r1.Exit -eq 0 -and $r1.Output -match 'settings from the record') "reapply exits 0 and says it used the record ($($r1.Output))"
|
|
$r1Changed = @($r1.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" })
|
|
Assert ($r1.Steps.Count -eq 12 -and (@($r1Changed | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "reapply changes exactly the missing three ($($r1Changed -join ' | '))"
|
|
$v2 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v2
|
|
Assert ($v2.Exit -eq 0 -and $v2.Output -match 'VERIFY OK') "verify passes after the reapply ($($v2.Exit))"
|
|
$r2 = Invoke-Mode 'Reapply'; Show 'Reapply again' $r2
|
|
Assert ($r2.Exit -eq 0 -and $r2.Steps.Count -eq 12 -and @($r2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second reapply: 12 steps, all unchanged ($(($r2.Steps | ForEach-Object Outcome) -join ','))"
|
|
Assert ($r2.Output -match 'summary: 12 step\(s\), 0 changed, 12 unchanged') 'the summary line says 0 changed'
|
|
|
|
Write-Host 'Negative control: Verify catches each kind of loss'
|
|
& icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # Users on the PE cache root
|
|
Remove-NetFirewallRule -Name $ruleName
|
|
$v3 = Invoke-Mode 'Verify'; Show 'Verify (cache ACL + rule broken)' $v3
|
|
Assert ($v3.Exit -eq 1 -and ($v3.Missing -join '|') -match 'PeCacheDir' -and ($v3.Missing -join '|') -match 'FirewallBlockOutbound') "verify flags the PE cache ACL and the missing rule ($($v3.Missing -join ' | '))"
|
|
$r3 = Invoke-Mode 'Reapply'; $v4 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v4
|
|
Assert ($r3.Exit -eq 0 -and $v4.Exit -eq 0) 'reapply repairs them'
|
|
|
|
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test)'
|
|
$swapScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'Invoke-OtOpcUaInstallSwap.ps1'
|
|
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
|
$cand2 = Join-Path $scratch 'install.new2'
|
|
New-Item -ItemType Directory -Path (Join-Path $cand2 'script-worker') -Force | Out-Null
|
|
Set-Content -LiteralPath (Join-Path $cand2 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v3'
|
|
$prev2 = Join-Path $scratch 'install.bak.2'
|
|
$sw = & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand2 -PreviousInstall $prev2 `
|
|
-Carry 'appsettings*.json,pki' -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String
|
|
$swExit = $LASTEXITCODE
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap', $swExit, $(if ($sw -match 'VERIFY OK') { 'VERIFY OK after its Reapply' } else { 'no VERIFY OK' }))
|
|
Assert ($swExit -eq 0 -and $sw -match 'VERIFY OK' -and $sw -match 'Swap complete') "swap script exits 0 with the gate passed ($sw)"
|
|
Assert ((Get-Content -LiteralPath (Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v3' -and (Test-Path (Join-Path $root 'pki')) -and
|
|
(Test-Path (Join-Path $root 'appsettings.json'))) 'the candidate is live and pki\ + appsettings were carried'
|
|
Assert (@(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1 -and
|
|
@(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'the three folder ACEs are back on the swapped-in folder'
|
|
|
|
Write-Host 'Swap refuses a pre-#901 record (DenyPaths only) before changing anything'
|
|
$saved = (Get-ItemProperty -Path $recordKey).InstallRoot
|
|
Remove-ItemProperty -Path $recordKey -Name InstallRoot
|
|
$cand3 = Join-Path $scratch 'install.new3'
|
|
New-Item -ItemType Directory -Path (Join-Path $cand3 'script-worker') -Force | Out-Null
|
|
$sw2 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand3 `
|
|
-PreviousInstall (Join-Path $scratch 'install.bak.3') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
$sw2Exit = $LASTEXITCODE
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap (pre-#901)', $sw2Exit, $(if (($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901') { 'refused: settings not recorded' } else { 'NOT refused' }))
|
|
Assert ($sw2Exit -ne 0 -and ($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901' -and (Test-Path $cand3) -and -not (Test-Path (Join-Path $scratch 'install.bak.3'))) "refused, nothing moved ($sw2)"
|
|
$r4 = Invoke-Mode 'Reapply'
|
|
Show 'Reapply (pre-#901 record)' $r4
|
|
Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))"
|
|
Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved
|
|
|
|
Write-Host 'Scoped -Reapply / -Verify after a raw swap (lmxopcua#905)'
|
|
$cand4 = Join-Path $scratch 'install.new4'
|
|
New-Item -ItemType Directory -Path (Join-Path $cand4 'script-worker') -Force | Out-Null
|
|
Set-Content -LiteralPath (Join-Path $cand4 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v4'
|
|
$prev4 = Join-Path $scratch 'install.bak.4'
|
|
Move-Item -LiteralPath $root -Destination $prev4
|
|
Move-Item -LiteralPath $cand4 -Destination $root
|
|
& robocopy (Join-Path $prev4 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
|
|
$vo = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (after swap)' $vo
|
|
Assert ($vo.Exit -eq 0 -and $vo.Output -match 'VERIFY OK \(scope Outside\): 9/9') "a swap leaves the 9 Outside steps intact ($($vo.Output))"
|
|
$vi = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); Show 'Verify -Scope InstallRoot (after swap)' $vi
|
|
Assert ($vi.Exit -eq 1 -and (@($vi.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|') -and ($vi.Steps.Count + $vi.Missing.Count) -eq 3) `
|
|
"the InstallRoot scope checks 3 steps and finds exactly the three dropped ACEs ($($vi.Missing -join ' | '))"
|
|
$ri = Invoke-Mode 'Reapply' @('-Scope', 'InstallRoot'); Show 'Reapply -Scope InstallRoot' $ri
|
|
$riChanged = @($ri.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" })
|
|
Assert ($ri.Exit -eq 0 -and $ri.Steps.Count -eq 3 -and (@($riChanged | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) `
|
|
"scoped reapply runs 3 steps and changes exactly the three ($($riChanged -join ' | '))"
|
|
$covLine = [regex]::Match($ri.Flat, 'coverage: \d+ denied folder.s.; \d+ folder.s. walked').Value
|
|
Assert ($ri.Flat -match 'coverage: 4 denied folder\(s\); 1 folder\(s\) walked') "scoped reapply walks InstallRoot only ($covLine)"
|
|
$vi2 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vAll = Invoke-Mode 'Verify'; Show 'Verify (full, after scoped Reapply)' $vAll
|
|
Assert ($vi2.Exit -eq 0 -and $vAll.Exit -eq 0 -and $vAll.Output -match 'VERIFY OK: 12/12') 'after the scoped reapply both the scoped and the full verify pass'
|
|
$scanLine = [regex]::Match($vAll.Flat, 'coverage scan: \d+ folder.s. walked').Value
|
|
Assert ($vAll.Flat -match 'coverage scan: 1 folder\(s\) walked') "the full verify walks the outermost denied folder once ($scanLine)"
|
|
|
|
Write-Host 'Negative control: each scope sees only its own entries'
|
|
& icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # an Outside entry broken
|
|
$vi3 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vo3 = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (cache broken)' $vo3
|
|
Assert ($vi3.Exit -eq 0 -and $vo3.Exit -eq 1 -and ($vo3.Missing -join '|') -eq "PeCacheDir $cache") "the cache ACL is Outside's to catch, not InstallRoot's ($($vo3.Missing -join ' | '))"
|
|
$ro3 = Invoke-Mode 'Reapply' @('-Scope', 'Outside')
|
|
Assert ($ro3.Exit -eq 0 -and (@($ro3.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ',') -eq 'PeCacheDir' -and $ro3.Steps.Count -eq 9) 'Reapply -Scope Outside repairs it (9 steps, 1 changed)'
|
|
|
|
Write-Host 'Coverage scan: protected items found once, script-worker\ skipped, scope-limited'
|
|
$prot = Join-Path $root 'pki\own.key'; $protSkip = Join-Path $root 'script-worker\private.key'
|
|
foreach ($f in $prot, $protSkip) { Set-Content -LiteralPath $f -Value 'k'; & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null }
|
|
$squash = { param($t) $t -replace '[\s|]+', '' }
|
|
$vc = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vco = Invoke-Mode 'Verify' @('-Scope', 'Outside'); $vca = Invoke-Mode 'Verify'
|
|
$hit = (& $squash $prot) + 'doesnotinherit'
|
|
Assert ($vc.Exit -eq 0 -and (& $squash $vc.Output).Contains($hit)) 'InstallRoot scope warns the protected pki\ file (a warning, not a failure)'
|
|
Assert (-not (& $squash $vc.Output).Contains((& $squash $protSkip))) 'script-worker\ is not walked'
|
|
Assert (-not (& $squash $vco.Output).Contains($hit)) 'the Outside scope skips InstallRoot'
|
|
Assert (([regex]::Matches((& $squash $vca.Output), [regex]::Escape($hit))).Count -eq 1) 'the full verify reports it exactly once (pki\ is inside two denied folders)'
|
|
foreach ($f in $prot, $protSkip) { & icacls $f /reset /Q | Out-Null; Remove-Item -LiteralPath $f -Force }
|
|
|
|
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 refuses a candidate without the worker exe the firewall rule names (lmxopcua#905)'
|
|
$cand6 = Join-Path $scratch 'install.new6'
|
|
New-Item -ItemType Directory -Path (Join-Path $cand6 'script-worker') -Force | Out-Null
|
|
$sw6 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand6 `
|
|
-PreviousInstall (Join-Path $scratch 'install.bak.6') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
$sw6Exit = $LASTEXITCODE
|
|
Assert ($sw6Exit -ne 0 -and ($sw6 -replace '[\s|]+', '') -match 'candidatehasno' -and (Test-Path $cand6) -and -not (Test-Path (Join-Path $scratch 'install.bak.6')) -and
|
|
$sw6 -notmatch 'Scope Outside') "refused in preflight, before the pre-stop isolation; nothing moved ($sw6)"
|
|
Remove-Item -LiteralPath $cand6 -Recurse -Force
|
|
|
|
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: the pre-stop gate stops before anything moves'
|
|
$dataDir = Join-Path $scratch 'data'
|
|
& icacls $dataDir /reset /Q | Out-Null; Remove-Item -LiteralPath $dataDir -Force # an Outside deny folder gone: -Reapply -Scope Outside refuses
|
|
$cand5 = Join-Path $scratch 'install.new5'
|
|
New-Item -ItemType Directory -Path (Join-Path $cand5 'script-worker') -Force | Out-Null
|
|
Set-Content -LiteralPath (Join-Path $cand5 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v5'
|
|
$prev5 = Join-Path $scratch 'install.bak.5'
|
|
$sw3 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 `
|
|
-PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
$sw3Exit = $LASTEXITCODE
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap with an Outside gap', $sw3Exit, $(if ((& $squash $sw3) -match 'ScopeOutsidefailed') { 'refused before the stop' } else { 'NOT refused' }))
|
|
Assert ($sw3Exit -ne 0 -and (& $squash $sw3) -match 'ScopeOutsidefailed.*nothingstoppedorswapped' -and (Test-Path $cand5) -and -not (Test-Path $prev5) -and
|
|
(Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw3 -notmatch '==> stop') "refused before the stop, nothing moved ($sw3)"
|
|
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
|
|
|
|
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoStart with service control (the stand-in host service is disabled: a start would fail)'
|
|
$sw4 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 `
|
|
-PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -NoStart -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
$sw4Exit = $LASTEXITCODE
|
|
$w4 = [regex]::Match($sw4, 'stop -> swapped and verified \(left stopped\): [^\r\n]*').Value
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoStart', $sw4Exit, $w4)
|
|
Assert ($sw4Exit -eq 0 -and $sw4 -match 'Swap complete' -and $w4 -and $sw4 -notmatch '==> start' -and (Get-Service $HostServiceName).Status -eq 'Stopped') "-NoStart: swapped, verified, not started ($sw4)"
|
|
Assert ($sw4 -match 'pre-stop isolation' -and $sw4 -match 'VERIFY OK \(scope Outside\)' -and $sw4 -match 'VERIFY OK \(scope InstallRoot\)') 'both scoped gates ran (Outside before the stop, InstallRoot after the swap)'
|
|
Assert ($sw4.IndexOf('VERIFY OK (scope Outside)') -lt $sw4.IndexOf('==> stop') -and $sw4.IndexOf('==> stop') -lt $sw4.IndexOf('-Reapply -Scope InstallRoot')) 'Outside gate before the stop, InstallRoot after it'
|
|
Assert ((Get-Content -LiteralPath $workerExe) -eq 'stub v5' -and @(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'v5 live, its ACEs in place'
|
|
|
|
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoCarry: the rollback swaps the previous install back'
|
|
$failed5 = Join-Path $scratch 'install.failed.5'
|
|
$sw5 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $prev5 `
|
|
-PreviousInstall $failed5 -NoCarry -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
$sw5Exit = $LASTEXITCODE
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoCarry (rollback)', $sw5Exit, [regex]::Match($sw5, 'swap window[^\r\n]*').Value)
|
|
Assert ($sw5Exit -eq 0 -and $sw5 -notmatch 'carried' -and (Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw5 -match 'VERIFY OK \(scope InstallRoot\)') "rollback: v4 back, nothing carried, gate passed ($sw5)"
|
|
$vEnd = Invoke-Mode 'Verify'
|
|
Assert ($vEnd.Exit -eq 0) 'full verify passes after the rollback'
|
|
|
|
# lmxopcua#908: on QA the host was still exiting when SCM said Stopped, and the first folder move failed "in use".
|
|
# Stand-in for that host: a copy of powershell.exe under InstallRoot (so its image is under InstallRoot) holding
|
|
# logs\host908.log open (FileShare.ReadWrite, no Delete: a folder rename fails while it is open; a running image alone
|
|
# does not block it, checked on windev) until a release file appears. The swap runs as a child whose output is
|
|
# watched, so the holder is released a fixed time after the swap reaches a given step (its pre-stop isolation gate
|
|
# takes several seconds, longer than any fixed holder lifetime would be safe against).
|
|
$winPs = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
$holderLog = Join-Path $root 'logs\host908.log'
|
|
$release = Join-Path $scratch 'release908'
|
|
function Start-InstallHolder([string]$Image) {
|
|
Remove-Item -LiteralPath $release -Force -ErrorAction SilentlyContinue
|
|
New-Item -ItemType Directory -Path (Join-Path $root 'logs') -Force | Out-Null
|
|
if ($Image -like "$root\*") { New-Item -ItemType Directory -Path (Split-Path -Parent $Image) -Force | Out-Null; Copy-Item -LiteralPath $winPs -Destination $Image -Force }
|
|
$cmd = "`$f = [IO.File]::Open('$holderLog', 'OpenOrCreate', 'Write', 'ReadWrite'); `$t = [Diagnostics.Stopwatch]::StartNew(); " +
|
|
"while (-not (Test-Path -LiteralPath '$release') -and `$t.Elapsed.TotalSeconds -lt 120) { Start-Sleep -Milliseconds 50 }; `$f.Dispose()"
|
|
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
|
|
$p = Start-Process -FilePath $Image -ArgumentList '-NoProfile', '-NonInteractive', '-EncodedCommand', $enc -WindowStyle Hidden -PassThru
|
|
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
|
while ([DateTime]::UtcNow -lt $deadline -and -not $p.HasExited -and -not (Test-Path -LiteralPath $holderLog)) { Start-Sleep -Milliseconds 100 }
|
|
Start-Sleep -Milliseconds 300
|
|
if ($p.HasExited) { throw "holder exited early ($($p.ExitCode))" }
|
|
$p
|
|
}
|
|
function Invoke-SwapReleasing([string[]]$SwapArgs, [string]$Marker, [int]$ReleaseAfterMs, $Holder) {
|
|
# Runs the swap; ReleaseAfterMs after $Marker appears in its output, releases the holder. Returns output + exit code.
|
|
$out = Join-Path $scratch 'swap908.out'; $err = Join-Path $scratch 'swap908.err'
|
|
$argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$swapScript`"") + ($SwapArgs | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } })
|
|
$sp = Start-Process -FilePath $exe -ArgumentList $argList -RedirectStandardOutput $out -RedirectStandardError $err -WindowStyle Hidden -PassThru
|
|
$null = $sp.Handle # Windows PowerShell 5.1: without an open handle ExitCode is $null after the exit
|
|
$seenAt = $null
|
|
while (-not $sp.HasExited) {
|
|
if (-not $seenAt -and (Test-Path $out) -and ((Get-Content -LiteralPath $out -Raw -ErrorAction SilentlyContinue) -like "*$Marker*")) { $seenAt = [DateTime]::UtcNow }
|
|
if ($seenAt -and ([DateTime]::UtcNow - $seenAt).TotalMilliseconds -ge $ReleaseAfterMs -and -not (Test-Path $release)) { New-Item -ItemType File -Path $release | Out-Null }
|
|
Start-Sleep -Milliseconds 50
|
|
}
|
|
$sp.WaitForExit()
|
|
if (-not (Test-Path $release)) { New-Item -ItemType File -Path $release | Out-Null }
|
|
$Holder.WaitForExit(30000) | Out-Null
|
|
$text = ((Get-Content -LiteralPath $out -Raw) + (Get-Content -LiteralPath $err -Raw)) -replace "\x1b\[[0-9;]*m", '' # no ANSI colour codes
|
|
[pscustomobject]@{ Exit = $sp.ExitCode; Output = $text }
|
|
}
|
|
function New-StubCandidate([string]$Dir, [string]$Stub) {
|
|
New-Item -ItemType Directory -Path (Join-Path $Dir 'script-worker') -Force | Out-Null
|
|
Set-Content -LiteralPath (Join-Path $Dir 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value $Stub
|
|
}
|
|
$holderExe = Join-Path $root 'holder908\OtOpcUa.Host908.exe'
|
|
$common = @('-Carry', 'appsettings*.json,pki', '-NoServiceControl', '-WorkerServiceName', $WorkerServiceName)
|
|
|
|
Write-Host 'lmxopcua#908: a process running from InstallRoot that outlives the budget: fails clearly, nothing moved'
|
|
$cand8a = Join-Path $scratch 'install.new8a'; $prev8a = Join-Path $scratch 'install.bak.8a'
|
|
New-StubCandidate $cand8a 'stub v8a'
|
|
$hold = Start-InstallHolder $holderExe
|
|
$r8a = Invoke-SwapReleasing (@('-InstallRoot', $root, '-Candidate', $cand8a, '-PreviousInstall', $prev8a, '-ProcessExitTimeoutSeconds', '1') + $common) 'Swap complete' 0 $hold
|
|
$sq8a = & $squash $r8a.Output
|
|
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap, holder kept, budget 1 s', $r8a.Exit, $(if ($sq8a -match 'stillinuse1safter') { 'refused: still in use' } else { 'NOT refused' }))
|
|
Assert ($r8a.Exit -ne 0 -and $sq8a -match 'stillinuse1safter.*OtOpcUa\.Host908\.exe\(PID' -and $sq8a -match 'Nothingwasmoved' -and
|
|
(Test-Path $cand8a) -and -not (Test-Path $prev8a) -and (Test-Path $holderExe)) "times out naming the holder, nothing moved ($($r8a.Output))"
|
|
Remove-Item -LiteralPath $holderLog -Force -ErrorAction SilentlyContinue
|
|
|
|
Write-Host 'lmxopcua#908: a process running from InstallRoot until ~2 s after the wait starts: the swap waits for it, then moves'
|
|
$hold = Start-InstallHolder $holderExe
|
|
$r8b = Invoke-SwapReleasing (@('-InstallRoot', $root, '-Candidate', $cand8a, '-PreviousInstall', $prev8a, '-MoveRetrySeconds', '0') + $common) '==> wait for the host process' 2000 $hold
|
|
$released = [regex]::Match($r8b.Output, 'released after ([\d.,]+) s').Groups[1].Value
|
|
Write-Host (" {0,-34} exit {1}; released after {2} s" -f 'swap, holder +2 s (no move retry)', $r8b.Exit, $released)
|
|
Assert ($r8b.Exit -eq 0 -and $r8b.Output -match 'Swap complete' -and $released -and [double]($released -replace ',', '.') -ge 1.5 -and
|
|
(Get-Content -LiteralPath $workerExe) -eq 'stub v8a' -and $r8b.Output -match 'VERIFY OK \(scope InstallRoot\)') "waited for the holder, then swapped and verified ($($r8b.Output))"
|
|
|
|
Write-Host 'lmxopcua#908: a handle the process wait cannot see (holder image outside InstallRoot): the first move is retried'
|
|
$cand8c = Join-Path $scratch 'install.new8c'; $prev8c = Join-Path $scratch 'install.bak.8c'
|
|
New-StubCandidate $cand8c 'stub v8c'
|
|
$hold = Start-InstallHolder $winPs
|
|
$r8c = Invoke-SwapReleasing (@('-InstallRoot', $root, '-Candidate', $cand8c, '-PreviousInstall', $prev8c, '-MoveRetrySeconds', '20') + $common) "==> move $root ->" 2000 $hold
|
|
$attempt = [regex]::Match($r8c.Output, 'moved on attempt (\d+)').Groups[1].Value
|
|
Write-Host (" {0,-34} exit {1}; moved on attempt {2}" -f 'swap, outside holder +2 s', $r8c.Exit, $attempt)
|
|
Assert ($r8c.Exit -eq 0 -and $attempt -and [int]$attempt -gt 1 -and (Get-Content -LiteralPath $workerExe) -eq 'stub v8c' -and
|
|
$r8c.Output -match 'VERIFY OK \(scope InstallRoot\)') "the in-use first move was retried and then went through ($($r8c.Output))"
|
|
$prevItems = @(Get-ChildItem -LiteralPath $prev8c -Recurse -Force | ForEach-Object { $_.FullName.Substring($prev8c.Length + 1) })
|
|
Assert (-not ($prevItems -contains 'install') -and ($prevItems -contains 'logs\host908.log') -and
|
|
(Get-Content -LiteralPath (Join-Path $prev8c 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v8a') `
|
|
"the previous install moved whole: no partial tree from a failed attempt, nothing nested into it ($($prevItems -join ', '))"
|
|
Remove-Item -LiteralPath $release -Force -ErrorAction SilentlyContinue
|
|
$v8 = Invoke-Mode 'Verify'
|
|
Assert ($v8.Exit -eq 0) 'full verify passes after the #908 swaps'
|
|
|
|
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
|
|
$u1 = Invoke-Installer @('-Uninstall')
|
|
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
|
|
Assert ($u1.Steps.Count -eq 10 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 10 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
|
Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed'
|
|
Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and
|
|
@(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0 -and
|
|
@($deny | Where-Object { @(Get-Explicit $_ $workerSid).Count -ne 0 }).Count -eq 0) 'no explicit worker ACEs remain, on the denied folders either'
|
|
Assert (-not (Test-Path $recordKey)) 'the record is gone'
|
|
Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache'
|
|
$env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
|
|
Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))"
|
|
Start-Sleep -Milliseconds 500
|
|
$u2 = Invoke-Installer @('-Uninstall')
|
|
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
|
|
Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
|
}
|
|
|
|
if ($TimingFiles -gt 0) {
|
|
# lmxopcua#905: a vd03-like layout. ApiInstall is denied as a whole and holds the install folder, the candidate and
|
|
# other app folders; pki\ is inside the install; 48 protected-DACL files (vd03 reported 48).
|
|
Write-Host "Swap timing (lmxopcua#905): scripts from $ScriptsDir"
|
|
$tInstaller = Join-Path $ScriptsDir 'Install-ScriptWorkerIsolation.ps1'
|
|
$tSwap = Join-Path $ScriptsDir 'Invoke-OtOpcUaInstallSwap.ps1'
|
|
$api = Join-Path $scratch 'ApiInstall'; $tRoot = Join-Path $api 'OtOpcUa'; $tCand = Join-Path $api 'OtOpcUa.new'
|
|
$tData = Join-Path $api 'OtOpcUa-data'; $tKek = Join-Path $scratch 'kek'; $tCache = Join-Path $scratch 'pe-cache-t'
|
|
function New-DummyApp([string]$Dir, [int]$N, [string]$Stub) {
|
|
for ($d = 0; $d -lt 60; $d++) { [IO.Directory]::CreateDirectory("$Dir\sub$d") | Out-Null }
|
|
for ($i = 0; $i -lt $N; $i++) { [IO.File]::WriteAllText("$Dir\sub$($i % 60)\f$i.dll", 'x') }
|
|
if ($Stub) { [IO.Directory]::CreateDirectory("$Dir\script-worker") | Out-Null; [IO.File]::WriteAllText("$Dir\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe", $Stub) }
|
|
}
|
|
New-DummyApp $tRoot 1400 'live'
|
|
foreach ($d in "$tRoot\pki\own\private", "$tRoot\logs", $tData, $tKek) { [IO.Directory]::CreateDirectory($d) | Out-Null }
|
|
[IO.File]::WriteAllText("$tRoot\appsettings.json", '{}')
|
|
New-DummyApp $tCand 1400 'candidate'
|
|
$left = [Math]::Max(0, $TimingFiles - 2800); $n = 0
|
|
while ($left -gt 0) { $n++; $take = [Math]::Min(1400, $left); New-DummyApp (Join-Path $api "app$n") $take ''; $left -= $take }
|
|
$protDir = if ($n) { Join-Path $api 'app1\sub0' } else { $tData }
|
|
for ($p = 0; $p -lt 48; $p++) { $f = "$protDir\prot$p.key"; [IO.File]::WriteAllText($f, 'k'); & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null }
|
|
$items = @([IO.DirectoryInfo]::new($api).EnumerateFileSystemInfos('*', [IO.SearchOption]::AllDirectories)).Count
|
|
$tDeny = "$api,$tRoot\pki,$tData,$tKek"
|
|
$ti = [Diagnostics.Stopwatch]::StartNew()
|
|
$tiOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -InstallRoot $tRoot -HostServiceAccount $script:hostAccount `
|
|
-PeCacheDir $tCache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService -DenyPaths $tDeny 2>&1 | Out-String }
|
|
$tiExit = $LASTEXITCODE; $ti.Stop()
|
|
Assert ($tiExit -eq 0) "timing: isolation install exits 0 ($tiOut)"
|
|
$ts = [Diagnostics.Stopwatch]::StartNew()
|
|
$tsOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tSwap -InstallRoot $tRoot -Candidate $tCand `
|
|
-PreviousInstall (Join-Path $api 'OtOpcUa.bak.t') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
$tsExit = $LASTEXITCODE; $ts.Stop()
|
|
Assert ($tsExit -eq 0 -and $tsOut -match 'Swap complete' -and [IO.File]::ReadAllText("$tRoot\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe") -eq 'candidate') "timing: swap exits 0 ($tsOut)"
|
|
$wm = [regex]::Match($tsOut, 'swap window[^:]*: (?<s>[0-9.,]+) s(?<rest>[^\r\n]*)')
|
|
# Before #905 the whole swap script ran inside the stopped window (nothing ran before the stop).
|
|
$window = if ($wm.Success) { [double]::Parse($wm.Groups['s'].Value, [Globalization.CultureInfo]::CurrentCulture) } else { $ts.Elapsed.TotalSeconds }
|
|
$pre = [regex]::Match($tsOut, 'pre-stop isolation (?<s>[0-9.,]+) s').Groups['s'].Value
|
|
Write-Host (" TIMING {0} {1}: {2} items under the denied folder; install {3:N1} s; swap script {4:N1} s; pre-stop {5}; STOPPED WINDOW {6:N1} s {7}" -f `
|
|
$PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $items, $ti.Elapsed.TotalSeconds, $ts.Elapsed.TotalSeconds,
|
|
$(if ($pre) { "$pre s" } else { 'none (all in the window)' }), $window, $wm.Groups['rest'].Value)
|
|
$tv = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Verify -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
Assert ($LASTEXITCODE -eq 0) 'timing: full verify passes after the swap'
|
|
$tu = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Uninstall -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
|
Assert ($LASTEXITCODE -eq 0 -and -not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) "timing: uninstall from the record ($tu)"
|
|
}
|
|
}
|
|
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
|
|
finally {
|
|
# Best-effort cleanup that does not depend on the code under test.
|
|
Remove-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
|
|
foreach ($n in $WorkerServiceName, $HostServiceName) {
|
|
if (Get-Service -Name $n -ErrorAction SilentlyContinue) { & sc.exe delete $n | Out-Null }
|
|
}
|
|
if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Remove-LocalUser -Name $LocalHostAccountName }
|
|
if (Test-Path -LiteralPath $scratch) {
|
|
& icacls $scratch /reset /T /C /Q 2>&1 | Out-Null
|
|
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host "$($script:passes) passed, $($script:failures) failed"
|
|
if ($script:failures -gt 0) { exit 1 }
|