param( [ValidateRange(0.01, 168)] [double]$DurationHours = 24, [ValidateRange(5, 3600)] [int]$SampleSeconds = 60, [ValidateRange(30, 86400)] [int]$ReconnectSeconds = 3600, [string]$OutputDirectory = 'artifacts/evidence/M10-14/raw' ) $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true Set-Location (Split-Path -Parent $PSScriptRoot) function Get-FreeLoopbackPort { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start() try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } } if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw 'Docker is required for the isolated wallboard soak.' } if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'Node.js is required for the wallboard soak runner.' } $goBin = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin' if (-not (Get-Command go -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $goBin 'go.exe'))) { $env:PATH = $goBin + ';' + $env:PATH } $project = 'itworx-pulse-soak-' + $PID $webPort = Get-FreeLoopbackPort $apiPort = Get-FreeLoopbackPort $dbPort = Get-FreeLoopbackPort $baseURL = "http://127.0.0.1:$webPort" $compose = @('-p', $project, '-f', 'deploy/compose.yaml', '-f', 'deploy/compose.dev.yaml', '-f', 'deploy/compose.smoke.yaml') $resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path (Get-Location) $OutputDirectory)) if (Test-Path -LiteralPath $resolvedOutput) { $existingArtifact = Get-ChildItem -LiteralPath $resolvedOutput -Force | Select-Object -First 1 if ($existingArtifact) { throw "Refusing to mix soak evidence in non-empty output directory: $resolvedOutput" } } New-Item -ItemType Directory -Force -Path $resolvedOutput | Out-Null $sourceCommit = (git rev-parse HEAD).Trim() $sourceDirty = [bool](git status --porcelain) $env:PULSE_POSTGRES_PASSWORD = 'soak-process-only' $env:PULSE_DATABASE_URL = 'postgres://pulse:soak-process-only@pulse-postgres:5432/pulse?sslmode=disable' $env:PULSE_ENV = 'development' $env:PULSE_AUTH_MODE = 'mock' $env:PULSE_DEV_WEB_PORT = [string]$webPort $env:PULSE_DEV_API_PORT = [string]$apiPort $env:PULSE_DEV_DB_PORT = [string]$dbPort $env:PULSE_SMOKE_WEBHOOK_TOKEN = 'soak-runtime-only' $env:PULSE_SOAK_BASE_URL = $baseURL $env:PULSE_SOAK_OUTPUT_DIR = $resolvedOutput $env:PULSE_SOAK_DURATION_HOURS = [string]$DurationHours $env:PULSE_SOAK_SAMPLE_SECONDS = [string]$SampleSeconds $env:PULSE_SOAK_RECONNECT_SECONDS = [string]$ReconnectSeconds try { Write-Host "== isolated 24-hour wallboard deployment ($project) ==" -ForegroundColor Cyan docker compose @compose config --quiet docker compose @compose build --quiet docker compose @compose up -d --wait --wait-timeout 240 docker compose @compose images --format json | Set-Content -Path (Join-Path $resolvedOutput 'launch-images.jsonl') -Encoding utf8 @{ project = $project; baseURL = $baseURL; startedAt = (Get-Date).ToUniversalTime().ToString('o'); durationHours = $DurationHours; sampleSeconds = $SampleSeconds; reconnectSeconds = $ReconnectSeconds; sourceCommit = $sourceCommit; sourceDirty = $sourceDirty } | ConvertTo-Json | Set-Content -Path (Join-Path $resolvedOutput 'stack.json') -Encoding utf8 node tools/wallboard-soak.mjs if ($LASTEXITCODE -ne 0) { throw 'wallboard soak runner failed' } docker compose @compose ps --format json | Set-Content -Path (Join-Path $resolvedOutput 'final-compose-ps.jsonl') -Encoding utf8 docker compose @compose logs --no-color --timestamps | Set-Content -Path (Join-Path $resolvedOutput 'final-compose.log') -Encoding utf8 Write-Host 'WALLBOARD SOAK: PASS' -ForegroundColor Green } finally { if (-not (Test-Path (Join-Path $resolvedOutput 'final-compose.log'))) { docker compose @compose ps --format json 2>$null | Set-Content -Path (Join-Path $resolvedOutput 'failure-compose-ps.jsonl') -Encoding utf8 docker compose @compose logs --no-color --timestamps 2>$null | Set-Content -Path (Join-Path $resolvedOutput 'failure-compose.log') -Encoding utf8 } docker compose @compose down --volumes --remove-orphans 2>$null Remove-Item Env:PULSE_POSTGRES_PASSWORD,Env:PULSE_DATABASE_URL,Env:PULSE_ENV,Env:PULSE_AUTH_MODE,Env:PULSE_DEV_WEB_PORT,Env:PULSE_DEV_API_PORT,Env:PULSE_DEV_DB_PORT,Env:PULSE_SMOKE_WEBHOOK_TOKEN,Env:PULSE_SOAK_BASE_URL,Env:PULSE_SOAK_OUTPUT_DIR,Env:PULSE_SOAK_DURATION_HOURS,Env:PULSE_SOAK_SAMPLE_SECONDS,Env:PULSE_SOAK_RECONNECT_SECONDS -ErrorAction SilentlyContinue }