|
|
|
@@ -0,0 +1,191 @@
|
|
|
|
|
[CmdletBinding()]
|
|
|
|
|
param(
|
|
|
|
|
[string]$PostgresBin = 'D:\pgsql17\bin',
|
|
|
|
|
[string]$Python = '',
|
|
|
|
|
[switch]$SkipIndependentProductE2E,
|
|
|
|
|
[switch]$KeepTemporary
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Set-StrictMode -Version 3.0
|
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
|
|
|
|
|
|
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
|
|
|
|
$temporaryRoot = [IO.Path]::GetFullPath((Join-Path ([IO.Path]::GetTempPath()) ('yovision-coordination-e2e-' + [guid]::NewGuid().ToString('N'))))
|
|
|
|
|
$postgresData = Join-Path $temporaryRoot 'postgres'
|
|
|
|
|
$postgresLog = Join-Path $temporaryRoot 'postgres.log'
|
|
|
|
|
$postgresStarted = $false
|
|
|
|
|
$savedEnvironment = @{}
|
|
|
|
|
$sensitiveValues = [Collections.Generic.List[string]]::new()
|
|
|
|
|
|
|
|
|
|
function Get-FreeTcpPort {
|
|
|
|
|
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
|
|
|
|
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
|
|
|
|
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
|
|
|
|
$client = [Net.Sockets.TcpClient]::new()
|
|
|
|
|
try { $connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected } catch { $connected = $false } finally { $client.Dispose() }
|
|
|
|
|
if ($connected -eq $Open) { return }
|
|
|
|
|
Start-Sleep -Milliseconds 250
|
|
|
|
|
}
|
|
|
|
|
throw "TCP port $Port did not reach open=$Open"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function New-RandomName([string]$Prefix) {
|
|
|
|
|
return $Prefix + '_' + [guid]::NewGuid().ToString('N').Substring(0, 12)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function New-RandomSecret {
|
|
|
|
|
$buffer = New-Object byte[] 48
|
|
|
|
|
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
|
|
|
|
|
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
|
|
|
|
|
return [Convert]::ToBase64String($buffer).Replace('+', 'A').Replace('/', 'B')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Set-TestEnvironment([string]$Name, [string]$Value, [bool]$Sensitive = $false) {
|
|
|
|
|
if (-not $script:savedEnvironment.ContainsKey($Name)) {
|
|
|
|
|
$script:savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
|
|
|
|
}
|
|
|
|
|
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
|
|
|
|
|
if ($Sensitive) { $script:sensitiveValues.Add($Value) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Invoke-Checked {
|
|
|
|
|
param([string]$Name, [string]$WorkingDirectory, [scriptblock]$Command)
|
|
|
|
|
Write-Host "[coordination-e2e] $Name"
|
|
|
|
|
Push-Location $WorkingDirectory
|
|
|
|
|
try {
|
|
|
|
|
& $Command
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" }
|
|
|
|
|
} finally { Pop-Location }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-NoSecretInLogs {
|
|
|
|
|
$logs = @(Get-ChildItem -LiteralPath $temporaryRoot -File -Recurse -ErrorAction SilentlyContinue)
|
|
|
|
|
foreach ($log in $logs) {
|
|
|
|
|
$stream = [IO.File]::Open($log.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)
|
|
|
|
|
try {
|
|
|
|
|
$reader = [IO.StreamReader]::new($stream, [Text.Encoding]::UTF8, $true)
|
|
|
|
|
try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() }
|
|
|
|
|
} finally { $stream.Dispose() }
|
|
|
|
|
foreach ($secret in $sensitiveValues) {
|
|
|
|
|
if ($secret.Length -ge 8 -and $content.Contains($secret)) {
|
|
|
|
|
throw "Temporary log exposed a generated E2E secret: $($log.Name)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
foreach ($tool in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
|
|
|
|
|
$path = Join-Path $PostgresBin $tool
|
|
|
|
|
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required PostgreSQL tool not found: $path" }
|
|
|
|
|
}
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Python)) {
|
|
|
|
|
$candidate = Join-Path $repositoryRoot 'Brain\.venv\Scripts\python.exe'
|
|
|
|
|
$Python = if (Test-Path -LiteralPath $candidate -PathType Leaf) { $candidate } else { 'python.exe' }
|
|
|
|
|
}
|
|
|
|
|
$contractEnvironment = Join-Path $temporaryRoot 'contract-venv'
|
|
|
|
|
& $Python -m venv $contractEnvironment
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw 'Could not create the isolated contract-test environment.' }
|
|
|
|
|
$contractPython = Join-Path $contractEnvironment 'Scripts\python.exe'
|
|
|
|
|
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
|
|
|
|
& $contractPython -m pip install --quiet -r (Join-Path $repositoryRoot 'contracts\tests\source-config-v1\requirements.txt') 'cryptography==50.0.1'
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw 'Could not install the pinned contract-test dependencies.' }
|
|
|
|
|
|
|
|
|
|
$postgresPort = Get-FreeTcpPort
|
|
|
|
|
if ($postgresPort -eq 5432) { throw 'Coordination E2E refuses the default PostgreSQL port.' }
|
|
|
|
|
$clusterUser = New-RandomName 'yvcoord'
|
|
|
|
|
$senseRole = New-RandomName 'sense_owner'
|
|
|
|
|
$bellRole = New-RandomName 'bell_owner'
|
|
|
|
|
$senseDatabase = New-RandomName 'sense_e2e'
|
|
|
|
|
$bellDatabase = New-RandomName 'bell_e2e'
|
|
|
|
|
|
|
|
|
|
& (Join-Path $PostgresBin 'initdb.exe') -D $postgresData -U $clusterUser -A trust --encoding=UTF8 --no-locale | Out-Null
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw 'Isolated PostgreSQL initdb failed.' }
|
|
|
|
|
$startArguments = "-D `"$postgresData`" -l `"$postgresLog`" -o `"-p $postgresPort -h 127.0.0.1`" start"
|
|
|
|
|
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $startArguments -RedirectStandardOutput (Join-Path $temporaryRoot 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporaryRoot 'pg-ctl.err.log') -WindowStyle Hidden | Out-Null
|
|
|
|
|
Wait-Tcp -Port $postgresPort -Open $true
|
|
|
|
|
$postgresStarted = $true
|
|
|
|
|
|
|
|
|
|
$psql = Join-Path $PostgresBin 'psql.exe'
|
|
|
|
|
foreach ($role in @($senseRole, $bellRole)) {
|
|
|
|
|
& $psql -X -h 127.0.0.1 -p $postgresPort -U $clusterUser -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE $role LOGIN;" | Out-Null
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw "Could not create isolated role $role" }
|
|
|
|
|
}
|
|
|
|
|
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $postgresPort -U $clusterUser -O $senseRole $senseDatabase
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw 'Could not create isolated Sense database.' }
|
|
|
|
|
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $postgresPort -U $clusterUser -O $bellRole $bellDatabase
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw 'Could not create isolated Bell database.' }
|
|
|
|
|
|
|
|
|
|
$senseDsn = "host=127.0.0.1 port=$postgresPort user=$senseRole dbname=$senseDatabase sslmode=disable"
|
|
|
|
|
$bellDsn = "host=127.0.0.1 port=$postgresPort user=$bellRole dbname=$bellDatabase sslmode=disable"
|
|
|
|
|
if ($senseDsn -eq $bellDsn -or $senseRole -eq $bellRole -or $senseDatabase -eq $bellDatabase) { throw 'Sense and Bell isolation invariant failed.' }
|
|
|
|
|
|
|
|
|
|
Set-TestEnvironment 'GOTOOLCHAIN' 'go1.26.5'
|
|
|
|
|
Set-TestEnvironment 'PYTHONDONTWRITEBYTECODE' '1'
|
|
|
|
|
Set-TestEnvironment 'SENSE_OUTBOX_TEST_DATABASE_URL' $senseDsn
|
|
|
|
|
Set-TestEnvironment 'BELL_DATABASE_URL' $bellDsn
|
|
|
|
|
Set-TestEnvironment 'BELL_EVENT_INGRESS_TEST_DATABASE_URL' $bellDsn
|
|
|
|
|
Set-TestEnvironment 'BELL_RULE_ALERT_TEST_DATABASE_URL' $bellDsn
|
|
|
|
|
Set-TestEnvironment 'BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL' $bellDsn
|
|
|
|
|
Set-TestEnvironment 'BELL_JWT_SECRET' (New-RandomSecret) $true
|
|
|
|
|
Set-TestEnvironment 'BELL_BOOTSTRAP_USERNAME' (New-RandomName 'coord_admin')
|
|
|
|
|
Set-TestEnvironment 'BELL_BOOTSTRAP_PASSWORD' (New-RandomSecret) $true
|
|
|
|
|
Set-TestEnvironment 'BELL_RULE_ALERT_OPERATOR_PASSWORD' (New-RandomSecret) $true
|
|
|
|
|
Set-TestEnvironment 'BELL_HOST' '127.0.0.1'
|
|
|
|
|
Set-TestEnvironment 'BELL_PORT' (Get-FreeTcpPort).ToString()
|
|
|
|
|
|
|
|
|
|
Invoke-Checked 'Bell formal migrations' (Join-Path $repositoryRoot 'Bell\server') { go run . migrate -c config/settings.demo.yml *> (Join-Path $temporaryRoot 'bell-migrate.log') }
|
|
|
|
|
|
|
|
|
|
Invoke-Checked 'source-config v1 contract' $repositoryRoot { & $contractPython -m unittest discover -s contracts/tests/source-config-v1 -p 'test_*.py' -v }
|
|
|
|
|
Invoke-Checked 'runtime-status v1 contract' $repositoryRoot { & $contractPython contracts/tests/runtime-status-v1/test_contract.py }
|
|
|
|
|
Invoke-Checked 'machine-identity v1 cross-language contract' $repositoryRoot { & $contractPython contracts/tests/machine-identity-v1/test_contract.py }
|
|
|
|
|
Invoke-Checked 'events v1 contract' $repositoryRoot { & $contractPython contracts/tests/events-v1/test_contract.py }
|
|
|
|
|
Invoke-Checked 'evidence v1 contract' $repositoryRoot { & $contractPython contracts/tests/evidence-v1/test_contract.py }
|
|
|
|
|
|
|
|
|
|
Invoke-Checked 'Sense source/status integration' (Join-Path $repositoryRoot 'Sense\tests\integration\brain_control') { go test . -count=1 -v }
|
|
|
|
|
Invoke-Checked 'Sense Brain-event/evidence/Outbox integration' (Join-Path $repositoryRoot 'Sense\tests\integration\bell_connector') { go test . -count=1 -v }
|
|
|
|
|
Invoke-Checked 'Sense PostgreSQL Outbox recovery' (Join-Path $repositoryRoot 'Sense\server') { go test ./app/sense/outbox -count=1 -v }
|
|
|
|
|
Invoke-Checked 'Brain source/status connector and anonymous event export' $repositoryRoot {
|
|
|
|
|
& $Python -m pytest Brain/tests/integration/sense_control Brain/tests/integration/event_export -q
|
|
|
|
|
}
|
|
|
|
|
Invoke-Checked 'Bell ingress and evidence degradation' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/integration/event_ingress -count=1 -v }
|
|
|
|
|
Invoke-Checked 'Bell rule and alert projection' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/bell_rule_alert -count=1 -v }
|
|
|
|
|
Invoke-Checked 'Bell alert lifecycle' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/bell_alert_lifecycle -count=1 -v }
|
|
|
|
|
|
|
|
|
|
$identityFacts = (& $psql -X -h 127.0.0.1 -p $postgresPort -U $clusterUser -d postgres -tAc "select datname||':'||pg_get_userbyid(datdba) from pg_database where datname in ('$senseDatabase','$bellDatabase') order by datname;")
|
|
|
|
|
if (@($identityFacts).Count -ne 2 -or ($identityFacts -join '|') -notmatch [regex]::Escape($senseRole) -or ($identityFacts -join '|') -notmatch [regex]::Escape($bellRole)) {
|
|
|
|
|
throw 'PostgreSQL ownership isolation evidence is incomplete.'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (-not $SkipIndependentProductE2E) {
|
|
|
|
|
Invoke-Checked 'Sense independent isolated E2E regression' $repositoryRoot { & (Join-Path $repositoryRoot 'Sense\tests\e2e\run-isolated-e2e.ps1') -PostgresBin $PostgresBin }
|
|
|
|
|
Invoke-Checked 'Brain independent test regression' $repositoryRoot { & $Python -m pytest Brain/tests -q }
|
|
|
|
|
Invoke-Checked 'Bell independent isolated E2E regression' $repositoryRoot { & (Join-Path $repositoryRoot 'Bell\tests\e2e\run-isolated-e2e.ps1') -PostgresBin $PostgresBin }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert-NoSecretInLogs
|
|
|
|
|
Write-Host "COORDINATION_E2E passed: versioned contracts, source/status, anonymous event, durable Outbox recovery, Bell Receipt/Event/Alert lifecycle, identity/replay/conflict/evidence faults, separate Sense/Bell databases. postgres_port=$postgresPort"
|
|
|
|
|
} finally {
|
|
|
|
|
if ($postgresStarted) {
|
|
|
|
|
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$postgresData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporaryRoot 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporaryRoot 'pg-stop.err.log') -WindowStyle Hidden -Wait | Out-Null
|
|
|
|
|
try { Wait-Tcp -Port $postgresPort -Open $false -Attempts 40 } catch {}
|
|
|
|
|
}
|
|
|
|
|
foreach ($entry in $savedEnvironment.GetEnumerator()) {
|
|
|
|
|
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
|
|
|
|
|
}
|
|
|
|
|
if ($KeepTemporary) {
|
|
|
|
|
Write-Host "Kept coordination E2E directory: $temporaryRoot"
|
|
|
|
|
} elseif (Test-Path -LiteralPath $temporaryRoot) {
|
|
|
|
|
$resolved = [IO.Path]::GetFullPath($temporaryRoot)
|
|
|
|
|
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
|
|
|
|
if (-not $resolved.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not ([IO.Path]::GetFileName($resolved)).StartsWith('yovision-coordination-e2e-')) {
|
|
|
|
|
throw "Refusing unsafe temporary cleanup: $resolved"
|
|
|
|
|
}
|
|
|
|
|
Remove-Item -LiteralPath $resolved -Recurse -Force
|
|
|
|
|
}
|
|
|
|
|
}
|