Files
ITWorx-Pulse-Public/scripts/integration-smoke.ps1
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

176 lines
10 KiB
PowerShell

$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
}