test: 建立 Bell 独立纵切与 Windows 交付验证 (#134)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:BellAllowedEnvironment = @(
|
||||
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
|
||||
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
|
||||
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
|
||||
'BELL_SYNTHETIC_EVENTS_ENABLED'
|
||||
)
|
||||
|
||||
function Get-BellPackageRoot {
|
||||
param([string]$ScriptDirectory = $PSScriptRoot)
|
||||
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
|
||||
}
|
||||
function Import-BellEnvironment {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" }
|
||||
$lineNumber = 0
|
||||
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$line = $rawLine.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
|
||||
$separator = $line.IndexOf('=')
|
||||
if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." }
|
||||
$name = $line.Substring(0, $separator).Trim()
|
||||
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" }
|
||||
$value = $line.Substring($separator + 1)
|
||||
if ($value.Length -ge 2) {
|
||||
$first, $last = $value[0], $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BellEnvironmentValue {
|
||||
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
|
||||
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
|
||||
return $value
|
||||
}
|
||||
|
||||
function Test-BellTcpEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
|
||||
}
|
||||
|
||||
function Test-BellListenPortAvailable {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
|
||||
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
|
||||
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
|
||||
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
|
||||
}
|
||||
|
||||
function Get-BellDatabaseEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$Connection)
|
||||
if ($Connection -match '^postgres(?:ql)?://') {
|
||||
$uri = [Uri]$Connection
|
||||
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
|
||||
}
|
||||
$values = @{}
|
||||
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
|
||||
$value = $match.Groups['value'].Value.Trim("'", '"')
|
||||
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
|
||||
}
|
||||
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
|
||||
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
|
||||
}
|
||||
|
||||
function Get-BellPort {
|
||||
param([string]$Name, [int]$Default)
|
||||
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
|
||||
$port = 0
|
||||
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
|
||||
return $port
|
||||
}
|
||||
|
||||
function Initialize-BellRuntime {
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
|
||||
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
|
||||
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
|
||||
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
|
||||
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
|
||||
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
|
||||
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
|
||||
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
|
||||
if (-not $AllowOccupiedPorts) {
|
||||
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
|
||||
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
|
||||
}
|
||||
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
|
||||
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
|
||||
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
|
||||
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
|
||||
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
|
||||
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
|
||||
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
|
||||
$webRoot = Join-Path $PackageRoot 'web'
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
|
||||
return [pscustomobject]@{
|
||||
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
|
||||
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
|
||||
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-BellHealth {
|
||||
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw "Bell health check timed out: $BaseUrl/healthz"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$WebRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ListenHost,
|
||||
[Parameter(Mandatory = $true)][int]$ListenPort,
|
||||
[Parameter(Mandatory = $true)][string]$BackendUrl
|
||||
)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
|
||||
$listener = [Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$handler.UseProxy = $false
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
|
||||
|
||||
try {
|
||||
$listener.Start()
|
||||
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
|
||||
while ($listener.IsListening) {
|
||||
$context = $listener.GetContext()
|
||||
try {
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
$path = $request.Url.AbsolutePath
|
||||
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
|
||||
$target = "$BackendUrl$($request.Url.PathAndQuery)"
|
||||
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
|
||||
if ($request.HasEntityBody) {
|
||||
$memory = [IO.MemoryStream]::new()
|
||||
$request.InputStream.CopyTo($memory)
|
||||
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
|
||||
$memory.Dispose()
|
||||
}
|
||||
foreach ($key in $request.Headers.AllKeys) {
|
||||
if ($key -in @('Host','Content-Length')) { continue }
|
||||
$values = $request.Headers.GetValues($key)
|
||||
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
|
||||
}
|
||||
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
|
||||
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
|
||||
$response.StatusCode = [int]$upstream.StatusCode
|
||||
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$message.Dispose(); $upstream.Dispose()
|
||||
} else {
|
||||
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
|
||||
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
|
||||
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
|
||||
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
|
||||
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
|
||||
$file = Join-Path $root 'index.html'
|
||||
}
|
||||
if ($response.StatusCode -ne 403) {
|
||||
$bytes = [IO.File]::ReadAllBytes($file)
|
||||
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
|
||||
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
|
||||
} finally {
|
||||
try { $context.Response.OutputStream.Close() } catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,12 @@
|
||||
param([switch]$Running)
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
|
||||
if ($Running) {
|
||||
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
|
||||
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
|
||||
}
|
||||
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
|
||||
exit 0
|
||||
} catch { Write-Error $_.Exception.Message; exit 1 }
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,41 @@
|
||||
param([switch]$SkipMigration)
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
|
||||
$backend = $null
|
||||
$pidFile = $null
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$state = Initialize-BellRuntime -PackageRoot $root
|
||||
$bell = Join-Path $root 'bell.exe'
|
||||
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
|
||||
$runtime = Join-Path $root 'runtime'
|
||||
$logs = Join-Path $runtime 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
|
||||
$pidFile = Join-Path $runtime 'bell.pid'
|
||||
if (Test-Path -LiteralPath $pidFile) {
|
||||
$oldPid = 0
|
||||
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
|
||||
Remove-Item -LiteralPath $pidFile -Force
|
||||
}
|
||||
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
|
||||
Push-Location $root
|
||||
try {
|
||||
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
|
||||
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
|
||||
Write-Host 'Applying pending Bell database migrations...'
|
||||
& $bell migrate -c $state.SettingsPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
|
||||
}
|
||||
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
|
||||
Wait-BellHealth -BaseUrl $state.BackendUrl
|
||||
Write-Host "Bell is available at $($state.WebUrl)/"
|
||||
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
|
||||
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
|
||||
} finally { Pop-Location }
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
} finally {
|
||||
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
|
||||
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,17 @@
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$pidFile = Join-Path $root 'runtime\bell.pid'
|
||||
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
|
||||
$processId = 0
|
||||
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
|
||||
$rootPattern = [regex]::Escape($root)
|
||||
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
|
||||
& taskkill.exe /PID $processId /T /F | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
|
||||
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host 'Bell backend and web process tree stopped.'
|
||||
exit 0
|
||||
} catch { Write-Error $_.Exception.Message; exit 1 }
|
||||
Reference in New Issue
Block a user