$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 integration smoke.' } $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-smoke-' + $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') $succeeded = $false $env:PULSE_POSTGRES_PASSWORD = 'smoke-process-only' $env:PULSE_DATABASE_URL = 'postgres://pulse:smoke-process-only@pulse-postgres:5432/pulse?sslmode=disable' $env:PULSE_ENV = 'development' $env:PULSE_AUTH_MODE = 'mock' $env:PULSE_SESSION_IDLE_TTL = '30s' $env:PULSE_SESSION_ABSOLUTE_TTL = '3m' $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 = 'smoke-runtime-only' try { Write-Host "== isolated stack ($project) ==" -ForegroundColor Cyan docker compose @compose config --quiet docker compose @compose build --quiet # Migrate and seed before the worker starts. Discovery claims one-minute # windows, so restarting a worker after an initial empty run is inherently # timing-dependent and can correctly retain the first claim until the next # window. docker compose @compose up -d --wait --wait-timeout 240 pulse-postgres docker compose @compose run --rm pulse-migrate Write-Host '== agent snapshot -> discovery -> inventory ==' -ForegroundColor Cyan @' INSERT INTO data_sources (id,type,name,configuration_ref) VALUES ('b1011111-1111-4111-8111-111111111111','unraid','Smoke container agent','smoke/container-agent') ON CONFLICT (id) DO NOTHING; INSERT INTO agent_snapshots (agent_id,capability,observed_at,received_at,payload) VALUES ( 'pulse-smoke-agent', 'containers', now(), now(), jsonb_build_object( 'Source', jsonb_build_object('id','pulse-smoke-agent','type','agent'), 'Containers', jsonb_build_array(jsonb_build_object( 'ID','runtime-smoke-1', 'Name','smoke-api', 'Image','itworx/pulse-smoke', 'ImageDigest','sha256:smoke', 'State','running', 'Health','healthy', 'Project','smoke', 'Labels',jsonb_build_object('com.docker.compose.project','smoke','com.docker.compose.service','api') )), 'ObservedAt', to_jsonb(now()), 'ReceivedAt', to_jsonb(now()) ) ) ON CONFLICT (agent_id,capability) DO UPDATE SET observed_at=EXCLUDED.observed_at,received_at=EXCLUDED.received_at,payload=EXCLUDED.payload; INSERT INTO agent_snapshots (agent_id,capability,observed_at,received_at,payload) VALUES ('pulse-smoke-agent','array',now(),now(),jsonb_build_object( 'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'), 'state','operational','parity',jsonb_build_object('present',true,'state','idle'), 'members',jsonb_build_array( jsonb_build_object('id','disk1','name','Disk 1','role','data','state','online','capacityBytes',1000000), jsonb_build_object('id','parity','name','Parity','role','parity','state','online','capacityBytes',1000000) ),'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now()) )), ('pulse-smoke-agent','disks',now(),now(),jsonb_build_object( 'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'), 'disks',jsonb_build_array(jsonb_build_object('id','disk1','name','Disk 1','role','data','state','online','sizeBytes',1000000,'usedBytes',400000)), 'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now()) )), ('pulse-smoke-agent','pools',now(),now(),jsonb_build_object( 'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'), 'pools',jsonb_build_array(jsonb_build_object('id','cache','name','Cache','filesystem','btrfs','state','healthy','usableBytes',1000000,'usedBytes',250000)), 'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now()) )), ('pulse-smoke-agent','shares',now(),now(),jsonb_build_object( 'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'), 'shares',jsonb_build_array(jsonb_build_object('id','share-media','name','Media','usedBytes',250000,'sizeObservedAt',to_jsonb(now()),'sizeState','cached')), 'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now()) )) ON CONFLICT (agent_id,capability) DO UPDATE SET observed_at=EXCLUDED.observed_at,received_at=EXCLUDED.received_at,payload=EXCLUDED.payload; '@ | docker compose @compose exec -T pulse-postgres psql -v ON_ERROR_STOP=1 -U pulse -d pulse | Out-Null if ($LASTEXITCODE -ne 0) { throw 'failed to seed bounded container agent snapshot' } docker compose @compose up -d --wait --wait-timeout 240 $inventoryReady = $false for ($attempt = 0; $attempt -lt 30; $attempt++) { Start-Sleep -Seconds 2 $inventoryRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT count(*) FROM entities WHERE canonical_name='smoke/api';" if ($LASTEXITCODE -eq 0 -and [int]($inventoryRows | Select-Object -Last 1) -eq 1) { $inventoryReady = $true; break } } if (-not $inventoryReady) { throw 'worker did not reconcile the fresh agent container snapshot' } $identityRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT (SELECT count(*) FROM entity_aliases WHERE source_id='b1011111-1111-4111-8111-111111111111' AND external_type='application-project' AND external_id='smoke')::text || ',' || (SELECT count(*) FROM entity_aliases WHERE source_id='b1011111-1111-4111-8111-111111111111' AND external_type='container-service' AND external_id='smoke/api')::text || ',' || (SELECT count(*) FROM container_aliases WHERE source_id='b1011111-1111-4111-8111-111111111111')::text;" if (($identityRows | Select-Object -Last 1) -ne '1,1,1') { throw "discovery identity is not idempotent: $identityRows" } $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new() Invoke-RestMethod -Uri "$baseURL/auth/test-login" -WebSession $session | Out-Null $rule = @{ schemaVersion = 1 id = 'f1011111-1111-4111-8111-111111111111' name = 'Integratiesmoke CPU' enabled = $true severity = 'critical' scope = @{ serverId = 'smoke-host' } condition = @{ inputType = 'metric'; metric = 'host.cpu.utilization'; operator = '>='; threshold = 1; aggregation = 'avg'; windowSeconds = 60 } evaluationIntervalSeconds = 5 pendingSeconds = 0 resolveSeconds = 0 cooldownSeconds = 60 unknownBehavior = 'become-unknown' groupBy = @() suppressWhen = @() message = @{ titleKey = 'smoke.cpu.title'; bodyKey = 'smoke.cpu.body' } } | ConvertTo-Json -Depth 8 -Compress Invoke-RestMethod -Method Post -Uri "$baseURL/api/v1/alert-rules" -WebSession $session -ContentType 'application/json' -Body $rule | Out-Null Write-Host '== source -> alert -> webhook ==' -ForegroundColor Cyan $fixtureStatus = $null for ($attempt = 0; $attempt -lt 45; $attempt++) { Start-Sleep -Seconds 2 $fixtureJSON = docker compose @compose exec -T pulse-smoke-fixture /pulse-integration-fixture get http://127.0.0.1:9090/smoke/status if ($LASTEXITCODE -ne 0) { continue } $fixtureStatus = $fixtureJSON | ConvertFrom-Json if ($fixtureStatus.prometheusRequests -gt 0 -and $fixtureStatus.events.firing -gt 0) { break } } # One custom smoke rule plus the two implementation-owned aggregate metric # defaults must all reach the approved Prometheus fixture. This guards the # alert-scope/catalog contract, not merely a single happy-path query. if ($null -eq $fixtureStatus -or $fixtureStatus.prometheusRequests -lt 3 -or $fixtureStatus.events.firing -lt 1) { throw "full alert chain did not complete: $($fixtureStatus | ConvertTo-Json -Compress)" } $workerLogs = docker compose @compose logs --no-color pulse-worker 2>$null if ($workerLogs -match 'alert metric query failed') { throw 'implementation-owned alert rule failed its bounded metric query' } if ($fixtureStatus.uniqueDeliveryKeys -gt $fixtureStatus.webhookRequests) { throw 'receiver observed inconsistent idempotency keys' } $snapshotRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT count(*) FROM agent_snapshots WHERE agent_id='pulse-smoke-agent' AND capability IN ('host','processes');" if ([int]($snapshotRows | Select-Object -Last 1) -lt 2) { throw "agent snapshots missing: $snapshotRows" } $deliveredRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT count(*) FROM notification_outbox WHERE event_type='firing' AND status='delivered';" if ([int]($deliveredRows | Select-Object -Last 1) -lt 1) { throw "delivered firing audit missing: $deliveredRows" } Write-Host '== real browser -> web proxy -> API -> PostgreSQL ==' -ForegroundColor Cyan $env:PULSE_E2E_REAL_BASE_URL = $baseURL pnpm --filter @itworx/pulse-web exec playwright test tests/e2e/real-stack.spec.ts --project=desktop-chromium if ($LASTEXITCODE -ne 0) { throw 'real-stack Playwright gate failed' } Write-Host ("INTEGRATION SMOKE: PASS sourceQueries={0} webhookAttempts={1} uniqueDeliveries={2}" -f $fixtureStatus.prometheusRequests, $fixtureStatus.webhookRequests, $fixtureStatus.uniqueDeliveryKeys) -ForegroundColor Green $succeeded = $true } finally { if (-not $succeeded) { docker compose @compose ps 2>$null docker compose @compose logs --no-color --tail 120 2>$null } # The project name is unique to this process. `-v` removes only the empty or # smoke-populated volume created above; no pre-existing Docker object can # match or be referenced by this compose project. 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_SESSION_IDLE_TTL,Env:PULSE_SESSION_ABSOLUTE_TTL,Env:PULSE_DEV_WEB_PORT,Env:PULSE_DEV_API_PORT,Env:PULSE_DEV_DB_PORT,Env:PULSE_SMOKE_WEBHOOK_TOKEN,Env:PULSE_E2E_REAL_BASE_URL -ErrorAction SilentlyContinue }