Files
chorus/scripts/chorus-dev.ps1
T

375 lines
12 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Position = 0)]
[ValidateSet("start", "stop", "restart", "status", "start-mock")]
[string]$Command = "start",
[Parameter(Position = 1)]
[string]$EnvironmentFile
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
throw "LOCALAPPDATA is required."
}
$script:RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$script:EnvironmentFile = $EnvironmentFile
$script:StateRoot = Join-Path $env:LOCALAPPDATA "Chorus\dev"
$script:BinRoot = Join-Path $script:StateRoot "bin"
$script:LogRoot = Join-Path $script:StateRoot "logs"
function Write-Step {
param([string]$Message)
Write-Host "[chorus] $Message"
}
function Get-StatePath {
param([ValidateSet("portal", "mock")][string]$Name)
return Join-Path $script:StateRoot "$Name.json"
}
function Read-State {
param([ValidateSet("portal", "mock")][string]$Name)
$path = Get-StatePath $Name
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
return $null
}
try {
return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
}
catch {
throw "State file is invalid: $path"
}
}
function Remove-State {
param([ValidateSet("portal", "mock")][string]$Name)
$path = Get-StatePath $Name
if (Test-Path -LiteralPath $path -PathType Leaf) {
Remove-Item -LiteralPath $path -Force
}
}
function Get-ManagedProcess {
param(
[ValidateSet("portal", "mock")][string]$Name,
[switch]$Quiet
)
$state = Read-State $Name
if ($null -eq $state) {
return $null
}
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if ($null -eq $process) {
Remove-State $Name
return $null
}
$expected = [IO.Path]::GetFullPath([string]$state.executable)
$actual = $null
try {
$actual = [IO.Path]::GetFullPath([string]$process.Path)
}
catch {
if (-not $Quiet) {
throw "Cannot verify executable path for $Name process $($state.pid); refusing to manage it."
}
return $null
}
if (-not [string]::Equals($expected, $actual, [StringComparison]::OrdinalIgnoreCase)) {
if (-not $Quiet) {
throw "PID $($state.pid) does not run the recorded $Name executable; refusing to manage it."
}
return $null
}
return [pscustomobject]@{ State = $state; Process = $process }
}
function Import-ChorusEnvironment {
if ([string]::IsNullOrWhiteSpace($EnvironmentFile)) {
if (-not [string]::IsNullOrWhiteSpace($env:CHORUS_ENV_FILE)) {
$script:EnvironmentFile = $env:CHORUS_ENV_FILE
}
else {
$script:EnvironmentFile = Join-Path (Split-Path $script:RepoRoot -Parent) "chorus-tools\chorus-test.env.ps1"
}
}
$resolved = Resolve-Path -LiteralPath $script:EnvironmentFile -ErrorAction SilentlyContinue
if ($null -eq $resolved) {
throw "Environment file not found: $script:EnvironmentFile"
}
$script:EnvironmentFile = $resolved.Path
. $script:EnvironmentFile
if ([string]::IsNullOrWhiteSpace($env:CHORUS_DSN)) {
throw "CHORUS_DSN is required in the environment file."
}
}
function Get-GoCommand {
if (-not [string]::IsNullOrWhiteSpace($env:CHORUS_GO)) {
$candidate = Resolve-Path -LiteralPath $env:CHORUS_GO -ErrorAction SilentlyContinue
if ($null -eq $candidate -or -not (Test-Path -LiteralPath $candidate.Path -PathType Leaf)) {
throw "CHORUS_GO does not point to an executable file."
}
return $candidate.Path
}
$command = Get-Command go.exe -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
$portable = Join-Path (Split-Path $script:RepoRoot -Parent) "chorus-tools\go1.26.5\bin\go.exe"
if (Test-Path -LiteralPath $portable -PathType Leaf) {
return $portable
}
throw "Go was not found. Add go.exe to PATH or set CHORUS_GO."
}
function Get-Endpoint {
param(
[string]$Value,
[string]$Fallback,
[string]$Name
)
if ([string]::IsNullOrWhiteSpace($Value)) {
$Value = $Fallback
}
if ($Value -notmatch '^(?<host>127\.0\.0\.1|localhost|\[::1\]):(?<port>\d{1,5})$') {
throw "$Name must use a loopback host and explicit port."
}
$port = [int]$Matches.port
if ($port -lt 1 -or $port -gt 65535) {
throw "$Name port is outside 1-65535."
}
return [pscustomobject]@{ Address = $Value; Host = $Matches.host.Trim('[', ']'); Port = $port }
}
function Test-TcpPort {
param([string]$HostName, [int]$Port, [int]$TimeoutMilliseconds = 500)
$client = [Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync($HostName, $Port)
if (-not $task.Wait($TimeoutMilliseconds)) {
return $false
}
return $client.Connected
}
catch {
return $false
}
finally {
$client.Dispose()
}
}
function Assert-DatabaseAvailable {
$hostName = $env:CHORUS_MYSQL_HOST
$portText = $env:CHORUS_MYSQL_PORT
if ([string]::IsNullOrWhiteSpace($hostName) -or [string]::IsNullOrWhiteSpace($portText)) {
if ($env:CHORUS_DSN -notmatch '@tcp\((?<host>[^:()]+):(?<port>\d+)\)') {
throw "Set CHORUS_MYSQL_HOST and CHORUS_MYSQL_PORT, or use a TCP CHORUS_DSN."
}
$hostName = $Matches.host
$portText = $Matches.port
}
if ($hostName -notin @("127.0.0.1", "localhost", "::1")) {
throw "The development launcher only accepts a local MySQL host."
}
$port = 0
if (-not [int]::TryParse($portText, [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
throw "CHORUS_MYSQL_PORT is invalid."
}
if (-not (Test-TcpPort $hostName $port 2000)) {
throw "Local MySQL is not reachable on the configured host and port."
}
}
function Assert-PortAvailable {
param($Endpoint, [string]$Name)
if (Test-TcpPort $Endpoint.Host $Endpoint.Port) {
throw "$Name cannot start because $($Endpoint.Address) is already in use."
}
}
function Build-Component {
param(
[ValidateSet("portal", "mock")][string]$Name,
[string]$Package
)
$go = Get-GoCommand
New-Item -ItemType Directory -Path $script:BinRoot -Force | Out-Null
$output = Join-Path $script:BinRoot "chorus-$Name.exe"
Write-Step "Building $Name..."
Push-Location $script:RepoRoot
try {
& $go build -o $output $Package
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) {
throw "Failed to build $Name."
}
}
finally {
Pop-Location
}
return [IO.Path]::GetFullPath($output)
}
function Wait-Portal {
param($Endpoint, [Diagnostics.Process]$Process, [string]$ErrorLog)
$urlHost = if ($Endpoint.Host -eq "::1") { "[::1]" } else { $Endpoint.Host }
$url = "http://${urlHost}:$($Endpoint.Port)/login"
$handler = [Net.Http.HttpClientHandler]::new()
$handler.UseProxy = $false
$client = [Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromSeconds(2)
try {
for ($attempt = 0; $attempt -lt 30; $attempt++) {
if ($Process.HasExited) {
throw "Portal exited during startup. See $ErrorLog"
}
try {
$response = $client.GetAsync($url).GetAwaiter().GetResult()
if ([int]$response.StatusCode -eq 200) {
$response.Dispose()
return $url
}
$response.Dispose()
}
catch {
Start-Sleep -Milliseconds 300
}
}
throw "Portal health check timed out. See $ErrorLog"
}
finally {
$client.Dispose()
$handler.Dispose()
}
}
function Start-Component {
param(
[ValidateSet("portal", "mock")][string]$Name,
[string]$Package,
$Endpoint
)
$existing = Get-ManagedProcess $Name
if ($null -ne $existing) {
Write-Step "$Name is already running (PID $($existing.State.pid))."
return
}
Assert-PortAvailable $Endpoint $Name
$executable = Build-Component $Name $Package
New-Item -ItemType Directory -Path $script:LogRoot -Force | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$stdout = Join-Path $script:LogRoot "$Name-$stamp.out.log"
$stderr = Join-Path $script:LogRoot "$Name-$stamp.err.log"
$process = Start-Process -FilePath $executable -WorkingDirectory $script:RepoRoot -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden
$state = [ordered]@{
name = $Name
pid = $process.Id
executable = $executable
address = $Endpoint.Address
stdout = $stdout
stderr = $stderr
started_at = (Get-Date).ToString("o")
}
$state | ConvertTo-Json | Set-Content -LiteralPath (Get-StatePath $Name) -Encoding UTF8
try {
if ($Name -eq "portal") {
$url = Wait-Portal $Endpoint $process $stderr
Write-Step "Portal URL: $url"
}
else {
for ($attempt = 0; $attempt -lt 20; $attempt++) {
if ($process.HasExited) {
throw "Mock provider exited during startup. See $stderr"
}
if (Test-TcpPort $Endpoint.Host $Endpoint.Port) {
Write-Step "Mock provider listening on $($Endpoint.Address)."
break
}
Start-Sleep -Milliseconds 250
}
if (-not (Test-TcpPort $Endpoint.Host $Endpoint.Port)) {
throw "Mock provider health check timed out. See $stderr"
}
}
}
catch {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
Remove-State $Name
throw
}
Write-Step "$Name PID: $($process.Id)"
Write-Step "$Name logs: $stdout and $stderr"
}
function Stop-Component {
param([ValidateSet("portal", "mock")][string]$Name)
$managed = Get-ManagedProcess $Name
if ($null -eq $managed) {
Write-Step "$name is not running."
return
}
Write-Step "Stopping $Name (PID $($managed.State.pid))..."
Stop-Process -Id $managed.Process.Id -Force
$managed.Process.WaitForExit(5000) | Out-Null
Remove-State $Name
Write-Step "$Name stopped."
}
function Show-Status {
foreach ($name in @("portal", "mock")) {
$managed = Get-ManagedProcess $name -Quiet
if ($null -eq $managed) {
Write-Step "${name}: stopped"
}
else {
Write-Step "${name}: running (PID $($managed.State.pid), $($managed.State.address))"
Write-Step "$name logs: $($managed.State.stdout) and $($managed.State.stderr)"
}
}
}
New-Item -ItemType Directory -Path $script:StateRoot -Force | Out-Null
try {
switch ($Command) {
"status" {
Show-Status
}
"stop" {
Stop-Component "mock"
Stop-Component "portal"
}
"start" {
Import-ChorusEnvironment
Assert-DatabaseAvailable
$portalEndpoint = Get-Endpoint $env:CHORUS_LISTEN_ADDRESS "127.0.0.1:8080" "CHORUS_LISTEN_ADDRESS"
$env:CHORUS_LISTEN_ADDRESS = $portalEndpoint.Address
Start-Component "portal" "./portal" $portalEndpoint
}
"start-mock" {
Import-ChorusEnvironment
$mockEndpoint = Get-Endpoint $env:CHORUS_MOCK_ADDR "127.0.0.1:18080" "CHORUS_MOCK_ADDR"
$env:CHORUS_MOCK_ADDR = $mockEndpoint.Address
Start-Component "mock" "./cmd/chorus-mock-provider" $mockEndpoint
}
"restart" {
Stop-Component "portal"
Import-ChorusEnvironment
Assert-DatabaseAvailable
$portalEndpoint = Get-Endpoint $env:CHORUS_LISTEN_ADDRESS "127.0.0.1:8080" "CHORUS_LISTEN_ADDRESS"
$env:CHORUS_LISTEN_ADDRESS = $portalEndpoint.Address
Start-Component "portal" "./portal" $portalEndpoint
}
}
}
catch {
[Console]::Error.WriteLine("[chorus] ERROR: " + $_.Exception.Message)
exit 1
}