Compare commits

...
Author SHA1 Message Date
QiuSW c2b2943a3a test: 增加三项目协调隔离 E2E (#155) 2026-08-31 17:34:37 +08:00
ila 55b12df373 docs: 同步根级部署编排说明 (#168)
用户于 2026-08-31 明确验收通过 #168。
2026-08-31 16:57:34 +08:00
3 changed files with 213 additions and 0 deletions
@@ -0,0 +1,4 @@
@echo off
setlocal
pwsh.exe -NoProfile -File "%~dp0run-coordination-e2e.ps1" %*
exit /b %ERRORLEVEL%
@@ -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
}
}
+18
View File
@@ -0,0 +1,18 @@
# Coordination E2E
Run the complete isolated coordination acceptance from the repository root:
```powershell
pwsh scripts/e2e/coordination/run-coordination-e2e.ps1
```
The runner creates a temporary PostgreSQL cluster on a dynamic loopback port,
uses distinct random owners and databases for Sense and Bell, exercises the
frozen contracts and the three connector chains, runs each product's existing
independent regression, checks generated secrets are absent from temporary
logs, and removes only the processes and directory it created.
`-SkipIndependentProductE2E` is intended only for local harness debugging and
does not satisfy issue #155 acceptance. `-KeepTemporary` preserves disposable
diagnostics after a failed run; the directory contains test-only generated
credentials and must not be committed or shared.