375 lines
12 KiB
PowerShell
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
|
|
}
|