Files
yovision/Bell/scripts/runtime/bell-common.ps1
T

118 lines
6.7 KiB
PowerShell

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