Files

442 lines
14 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Position = 0)]
[ValidateSet("start", "stop", "status")]
[string]$Command = "start",
[Parameter(Position = 1)]
[string]$ConfigFile
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
throw "LOCALAPPDATA is required."
}
$script:RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if ([string]::IsNullOrWhiteSpace($ConfigFile)) {
$ConfigFile = Join-Path $script:RepoRoot "config\local-services.yml"
}
$script:StateRoot = Join-Path $env:LOCALAPPDATA "Chorus\all"
$script:BinRoot = Join-Path $script:StateRoot "bin"
$script:LogRoot = Join-Path $script:StateRoot "logs"
$script:PortalStatePath = Join-Path $env:LOCALAPPDATA "Chorus\dev\portal.json"
function Write-Step {
param([string]$Message)
Write-Host "[chorus-all] $Message"
}
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 Read-LocalServicesConfig {
$go = Get-GoCommand
Push-Location $script:RepoRoot
try {
$output = & $go run ./cmd/chorus-local-config -config $ConfigFile
if ($LASTEXITCODE -ne 0) {
throw "Local services configuration validation failed."
}
}
finally {
Pop-Location
}
try {
return ($output -join [Environment]::NewLine) | ConvertFrom-Json
}
catch {
throw "Local services configuration returned invalid JSON."
}
}
function ConvertTo-Endpoint {
param([string]$Address)
if ($Address -notmatch '^\[?(?<host>[^\]]+)\]?:(?<port>\d+)$') {
throw "Resolved service address is invalid."
}
return [pscustomobject]@{ Address = $Address; Host = $Matches.host; Port = [int]$Matches.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 Get-StatePath {
param([ValidateSet("admin", "admin-ui")][string]$Name)
return Join-Path $script:StateRoot "$Name.json"
}
function Read-State {
param([ValidateSet("admin", "admin-ui")][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("admin", "admin-ui")][string]$Name)
$path = Get-StatePath $Name
if (Test-Path -LiteralPath $path -PathType Leaf) {
Remove-Item -LiteralPath $path -Force
}
}
function Get-ManagedProcess {
param([ValidateSet("admin", "admin-ui")][string]$Name)
$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
}
try {
$expected = [IO.Path]::GetFullPath([string]$state.executable)
$actual = [IO.Path]::GetFullPath([string]$process.Path)
}
catch {
throw "Cannot verify executable path for $Name process $($state.pid); refusing to manage it."
}
if (-not [string]::Equals($expected, $actual, [StringComparison]::OrdinalIgnoreCase)) {
throw "PID $($state.pid) does not run the recorded $Name executable; refusing to manage it."
}
return [pscustomobject]@{ State = $state; Process = $process }
}
function Assert-PortAvailable {
param($Endpoint, [string]$Name)
if (Test-TcpPort $Endpoint.Host $Endpoint.Port) {
throw "$Name cannot start because $($Endpoint.Address) is used by an unmanaged process."
}
}
function Wait-Tcp {
param($Endpoint, [Diagnostics.Process]$Process, [string]$ErrorLog, [string]$Name)
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if ($Process.HasExited) {
throw "$Name exited during startup. See $ErrorLog"
}
if (Test-TcpPort $Endpoint.Host $Endpoint.Port) {
return
}
Start-Sleep -Milliseconds 250
}
throw "$Name startup timed out. See $ErrorLog"
}
function Wait-AdminUI {
param($Endpoint, [Diagnostics.Process]$Process, [string]$ErrorLog)
$urlHost = if ($Endpoint.Host -eq "::1") { "[::1]" } else { $Endpoint.Host }
$url = "http://${urlHost}:$($Endpoint.Port)/"
$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 120; $attempt++) {
if ($Process.HasExited) {
throw "admin-ui 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 500
}
}
throw "admin-ui startup timed out. See $ErrorLog"
}
finally {
$client.Dispose()
$handler.Dispose()
}
}
function Start-ManagedProcess {
param(
[ValidateSet("admin", "admin-ui")][string]$Name,
[string]$Executable,
[string[]]$Arguments,
[string]$WorkingDirectory,
$Endpoint,
[switch]$HTTP
)
$existing = Get-ManagedProcess $Name
if ($null -ne $existing) {
Write-Step "$Name is already running (PID $($existing.State.pid))."
return $false
}
Assert-PortAvailable $Endpoint $Name
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 -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden
$state = [ordered]@{
name = $Name
pid = $process.Id
executable = [IO.Path]::GetFullPath($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 ($HTTP) {
$url = Wait-AdminUI $Endpoint $process $stderr
Write-Step "Admin UI URL: $url"
}
else {
Wait-Tcp $Endpoint $process $stderr $Name
Write-Step "$Name listening on $($Endpoint.Address)."
}
}
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"
return $true
}
function Build-Admin {
$go = Get-GoCommand
New-Item -ItemType Directory -Path $script:BinRoot -Force | Out-Null
$output = Join-Path $script:BinRoot "chorus-admin.exe"
Write-Step "Building admin..."
Push-Location $script:RepoRoot
try {
& $go -C admin build -o $output .
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) {
throw "Failed to build admin."
}
}
finally {
Pop-Location
}
return [IO.Path]::GetFullPath($output)
}
function Start-Admin {
param($Resolved)
$endpoint = ConvertTo-Endpoint $Resolved.admin_address
if ($null -ne (Get-ManagedProcess "admin")) {
Write-Step "admin is already running."
return $false
}
$executable = Build-Admin
$settingsArgument = '"' + $Resolved.admin_settings_file.Replace('"', '\"') + '"'
return Start-ManagedProcess "admin" $executable @("server", "--config", $settingsArgument) (Join-Path $script:RepoRoot "admin") $endpoint
}
function Start-AdminUI {
param($Resolved)
$endpoint = ConvertTo-Endpoint $Resolved.admin_ui_address
if ($null -ne (Get-ManagedProcess "admin-ui")) {
Write-Step "admin-ui is already running."
return $false
}
$node = Get-Command node.exe -ErrorAction SilentlyContinue
if ($null -eq $node) {
throw "Node was not found. Install the locked Node version before starting admin-ui."
}
$entry = Join-Path $script:RepoRoot "admin-ui\node_modules\@vue\cli-service\bin\vue-cli-service.js"
if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) {
throw "admin-ui dependencies are missing. Run corepack pnpm --dir admin-ui install --frozen-lockfile."
}
$previous = $env:VUE_APP_BASE_API
try {
$env:VUE_APP_BASE_API = $Resolved.admin_ui_api_base
$entryArgument = '"' + $entry.Replace('"', '\"') + '"'
return Start-ManagedProcess "admin-ui" $node.Source @($entryArgument, "serve", "--host", $endpoint.Host, "--port", "$($endpoint.Port)") (Join-Path $script:RepoRoot "admin-ui") $endpoint -HTTP
}
finally {
$env:VUE_APP_BASE_API = $previous
}
}
function Test-PortalManaged {
if (-not (Test-Path -LiteralPath $script:PortalStatePath -PathType Leaf)) {
return $false
}
try {
$state = Get-Content -LiteralPath $script:PortalStatePath -Raw | ConvertFrom-Json
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if ($null -eq $process) {
return $false
}
return [string]::Equals([IO.Path]::GetFullPath([string]$state.executable), [IO.Path]::GetFullPath([string]$process.Path), [StringComparison]::OrdinalIgnoreCase)
}
catch {
return $false
}
}
function Start-Portal {
param($Resolved)
if (Test-PortalManaged) {
Write-Step "portal is already running."
return $false
}
$endpoint = ConvertTo-Endpoint $Resolved.portal_address
Assert-PortAvailable $endpoint "portal"
& (Join-Path $PSScriptRoot "chorus-dev.bat") start $Resolved.portal_environment_file $Resolved.portal_address $Resolved.portal_settings_file
if ($LASTEXITCODE -ne 0) {
throw "Portal startup failed."
}
return $true
}
function Stop-ManagedProcess {
param([ValidateSet("admin", "admin-ui")][string]$Name)
$managed = Get-ManagedProcess $Name
if ($null -eq $managed) {
Write-Step "$Name is not managed or is already stopped."
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 Stop-Portal {
if (-not (Test-PortalManaged)) {
Write-Step "portal is not managed or is already stopped."
return
}
& (Join-Path $PSScriptRoot "chorus-dev.bat") stop-portal
if ($LASTEXITCODE -ne 0) {
throw "Portal stop failed."
}
}
function Show-ServiceStatus {
param([ValidateSet("admin", "admin-ui")][string]$Name, $Endpoint)
$managed = Get-ManagedProcess $Name
if ($null -ne $managed) {
Write-Step "${Name}: managed running (PID $($managed.State.pid), $($managed.State.address))"
Write-Step "$Name logs: $($managed.State.stdout) and $($managed.State.stderr)"
}
elseif (Test-TcpPort $Endpoint.Host $Endpoint.Port) {
Write-Step "${Name}: unmanaged listener on $($Endpoint.Address)"
}
else {
Write-Step "${Name}: stopped ($($Endpoint.Address))"
}
}
function Show-PortalStatus {
param($Endpoint)
if (Test-PortalManaged) {
$state = Get-Content -LiteralPath $script:PortalStatePath -Raw | ConvertFrom-Json
Write-Step "portal: managed running (PID $($state.pid), $($state.address))"
Write-Step "portal logs: $($state.stdout) and $($state.stderr)"
}
elseif (Test-TcpPort $Endpoint.Host $Endpoint.Port) {
Write-Step "portal: unmanaged listener on $($Endpoint.Address)"
}
else {
Write-Step "portal: stopped ($($Endpoint.Address))"
}
}
New-Item -ItemType Directory -Path $script:StateRoot -Force | Out-Null
try {
$resolved = Read-LocalServicesConfig
$portalEndpoint = ConvertTo-Endpoint $resolved.portal_address
$adminEndpoint = ConvertTo-Endpoint $resolved.admin_address
$adminUIEndpoint = ConvertTo-Endpoint $resolved.admin_ui_address
switch ($Command) {
"status" {
Show-PortalStatus $portalEndpoint
Show-ServiceStatus "admin" $adminEndpoint
Show-ServiceStatus "admin-ui" $adminUIEndpoint
}
"stop" {
Stop-ManagedProcess "admin-ui"
Stop-ManagedProcess "admin"
Stop-Portal
}
"start" {
$portalStarted = $false
$adminStarted = $false
try {
$portalStarted = Start-Portal $resolved
$adminStarted = Start-Admin $resolved
$null = Start-AdminUI $resolved
}
catch {
if ($adminStarted) {
Stop-ManagedProcess "admin"
}
if ($portalStarted) {
Stop-Portal
}
throw
}
Show-PortalStatus $portalEndpoint
Show-ServiceStatus "admin" $adminEndpoint
Show-ServiceStatus "admin-ui" $adminUIEndpoint
}
}
}
catch {
[Console]::Error.WriteLine("[chorus-all] ERROR: " + $_.Exception.Message)
exit 1
}