442 lines
14 KiB
PowerShell
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
|
|
}
|