Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' $script:CoordinationProductNames = @('sense', 'brain', 'bell') $script:CoordinationStartOrder = @('bell', 'sense', 'brain') $script:CoordinationStopOrder = @('brain', 'sense', 'bell') function Get-CoordinationRepositoryRoot { return [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) } function Resolve-CoordinationPath { param([Parameter(Mandatory = $true)][string]$Base, [Parameter(Mandatory = $true)][string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { throw 'A required path is empty.' } if ([IO.Path]::IsPathRooted($Value)) { return [IO.Path]::GetFullPath($Value) } return [IO.Path]::GetFullPath((Join-Path $Base $Value)) } function Test-CoordinationPathWithin { param([Parameter(Mandatory = $true)][string]$Child, [Parameter(Mandatory = $true)][string]$Parent) $childPath = [IO.Path]::GetFullPath($Child).TrimEnd('\', '/') $parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') return $childPath.Equals($parentPath, [StringComparison]::OrdinalIgnoreCase) -or $childPath.StartsWith($parentPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) } function Get-CoordinationProperty { param([Parameter(Mandatory = $true)]$Object, [Parameter(Mandatory = $true)][string]$Name, [switch]$Optional) $property = $Object.PSObject.Properties[$Name] if (-not $property) { if ($Optional) { return $null } throw "Missing manifest property: $Name" } return $property.Value } function Assert-CoordinationProperties { param([Parameter(Mandatory = $true)]$Object, [Parameter(Mandatory = $true)][string[]]$Allowed, [Parameter(Mandatory = $true)][string]$Context) foreach ($property in $Object.PSObject.Properties.Name) { if ($property -notin $Allowed) { throw "$Context contains unsupported property: $property" } } } function Read-CoordinationEnvironment { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Environment file not found: $Path" } $values = @{} $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 environment file at line $lineNumber. Expected NAME=value." } $name = $line.Substring(0, $separator).Trim() if ($name -notmatch '^[A-Z][A-Z0-9_]{1,127}$') { throw "Invalid environment variable name at line $lineNumber." } if ($values.ContainsKey($name)) { throw "Duplicate environment variable 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) } } $values[$name] = $value } return $values } function Assert-CoordinationExternalSecretPath { param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$RepositoryRoot, [Parameter(Mandatory = $true)][string[]]$PackageRoots) if (Test-CoordinationPathWithin -Child $Path -Parent $RepositoryRoot) { throw 'Secret or environment files must be outside the repository.' } foreach ($packageRoot in $PackageRoots) { if (Test-CoordinationPathWithin -Child $Path -Parent $packageRoot) { throw 'Secret or environment files must be outside product packages.' } } } function Assert-CoordinationDistinctPaths { param([Parameter(Mandatory = $true)][object[]]$Entries) for ($left = 0; $left -lt $Entries.Count; $left++) { for ($right = $left + 1; $right -lt $Entries.Count; $right++) { if ((Test-CoordinationPathWithin -Child $Entries[$left].Path -Parent $Entries[$right].Path) -or (Test-CoordinationPathWithin -Child $Entries[$right].Path -Parent $Entries[$left].Path)) { throw "Deployment paths overlap: $($Entries[$left].Label) and $($Entries[$right].Label)." } } } } function Resolve-CoordinationCommand { param([Parameter(Mandatory = $true)][string]$PackageRoot, [Parameter(Mandatory = $true)]$Command) Assert-CoordinationProperties -Object $Command -Allowed @('executable', 'arguments') -Context 'command' $path = Resolve-CoordinationPath -Base $PackageRoot -Value ([string](Get-CoordinationProperty -Object $Command -Name 'executable')) if (-not (Test-CoordinationPathWithin -Child $path -Parent $PackageRoot)) { throw 'Product commands must be inside their package root.' } if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Product command not found: $path" } $rawArguments = Get-CoordinationProperty -Object $Command -Name 'arguments' if ($rawArguments -is [string]) { throw 'Command arguments must be an array.' } $arguments = @($rawArguments) | ForEach-Object { [string]$_ } return [pscustomobject]@{ Path = $path; Arguments = @($arguments) } } function Get-CoordinationLauncher { param([Parameter(Mandatory = $true)]$Command) $extension = [IO.Path]::GetExtension($Command.Path).ToLowerInvariant() if ($extension -eq '.ps1') { $pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source return [pscustomobject]@{ Executable = $pwsh; Arguments = @('-NoProfile', '-File', $Command.Path) + @($Command.Arguments); CommandToken = $Command.Path } } if ($extension -in @('.bat', '.cmd')) { $cmd = (Get-Command cmd.exe -ErrorAction Stop).Source return [pscustomobject]@{ Executable = $cmd; Arguments = @('/d', '/c', $Command.Path) + @($Command.Arguments); CommandToken = $Command.Path } } return [pscustomobject]@{ Executable = $Command.Path; Arguments = @($Command.Arguments); CommandToken = $Command.Path } } function ConvertTo-CoordinationArgument { param([AllowEmptyString()][string]$Value) if ($Value -notmatch '[\s"]') { return $Value } return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' } function Invoke-CoordinationEnvironment { param([Parameter(Mandatory = $true)][hashtable]$Values, [Parameter(Mandatory = $true)][scriptblock]$Action) $saved = @{} try { foreach ($name in $Values.Keys) { $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') [Environment]::SetEnvironmentVariable($name, [string]$Values[$name], 'Process') } return & $Action } finally { foreach ($name in $Values.Keys) { [Environment]::SetEnvironmentVariable($name, $saved[$name], 'Process') } } } function Get-CoordinationFileDigest { param([Parameter(Mandatory = $true)][string]$Path) return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } function Get-CoordinationDatabaseIdentity { param([Parameter(Mandatory = $true)][string]$Connection) if ($Connection -match '^postgres(?:ql)?://') { $uri = [Uri]$Connection $role = if ($uri.UserInfo) { [Uri]::UnescapeDataString(($uri.UserInfo -split ':', 2)[0]) } else { '' } return [pscustomobject]@{ Database = [Uri]::UnescapeDataString($uri.AbsolutePath.Trim('/')); Role = $role } } $database = if ($Connection -match '(?i)(?:^|\s)(?:dbname|database)\s*=\s*(?:''([^'']+)''|"([^"]+)"|([^\s]+))') { @($Matches[1], $Matches[2], $Matches[3]) | Where-Object { $_ } | Select-Object -First 1 } else { '' } $role = if ($Connection -match '(?i)(?:^|\s)(?:user|username)\s*=\s*(?:''([^'']+)''|"([^"]+)"|([^\s]+))') { @($Matches[1], $Matches[2], $Matches[3]) | Where-Object { $_ } | Select-Object -First 1 } else { '' } return [pscustomobject]@{ Database = [string]$database; Role = [string]$role } } function Read-CoordinationManifest { param([Parameter(Mandatory = $true)][string]$Manifest) $manifestPath = [IO.Path]::GetFullPath($Manifest) if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw "Coordination manifest not found: $manifestPath" } try { $raw = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 64 } catch { throw "Coordination manifest is not valid JSON: $($_.Exception.Message)" } Assert-CoordinationProperties -Object $raw -Allowed @('schema_version', 'deployment_id', 'runtime_root', 'products') -Context 'manifest' if ((Get-CoordinationProperty -Object $raw -Name 'schema_version') -ne 'yovision.coordination/v1') { throw 'Unsupported coordination manifest schema version.' } $deploymentID = [string](Get-CoordinationProperty -Object $raw -Name 'deployment_id') if ($deploymentID -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$') { throw 'Invalid deployment_id.' } $manifestRoot = Split-Path -Parent $manifestPath $runtimeRoot = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $raw -Name 'runtime_root')) $rawProducts = Get-CoordinationProperty -Object $raw -Name 'products' Assert-CoordinationProperties -Object $rawProducts -Allowed $script:CoordinationProductNames -Context 'products' $products = @() foreach ($name in $script:CoordinationProductNames) { $item = Get-CoordinationProperty -Object $rawProducts -Name $name Assert-CoordinationProperties -Object $item -Allowed @('enabled', 'version', 'package_root', 'environment_file', 'data_directory', 'log_directory', 'ports', 'browser_origin', 'cookie_name', 'account_namespace', 'database_id', 'database_role', 'start', 'stop', 'health', 'identities') -Context $name $rawEnabled = Get-CoordinationProperty -Object $item -Name 'enabled' if ($rawEnabled -isnot [bool]) { throw "enabled must be a boolean for ${name}." } $version = [string](Get-CoordinationProperty -Object $item -Name 'version') if ([string]::IsNullOrWhiteSpace($version)) { throw "version is required for ${name}." } $packageRoot = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'package_root')) if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { throw "Package root not found for ${name}: $packageRoot" } $environmentFile = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'environment_file')) $dataDirectory = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'data_directory')) $logDirectory = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'log_directory')) $start = Resolve-CoordinationCommand -PackageRoot $packageRoot -Command (Get-CoordinationProperty -Object $item -Name 'start') $rawStop = Get-CoordinationProperty -Object $item -Name 'stop' -Optional $stop = if ($null -eq $rawStop) { $null } else { Resolve-CoordinationCommand -PackageRoot $packageRoot -Command $rawStop } $ports = @((Get-CoordinationProperty -Object $item -Name 'ports')) | ForEach-Object { [int]$_ } foreach ($port in $ports) { if ($port -lt 1 -or $port -gt 65535) { throw "Invalid port for ${name}." } } $health = Get-CoordinationProperty -Object $item -Name 'health' Assert-CoordinationProperties -Object $health -Allowed @('kind', 'url', 'timeout_seconds') -Context "$name health" $healthKind = [string](Get-CoordinationProperty -Object $health -Name 'kind') $healthURL = [string](Get-CoordinationProperty -Object $health -Name 'url' -Optional) $healthTimeout = [int](Get-CoordinationProperty -Object $health -Name 'timeout_seconds') if ($healthKind -notin @('process', 'http') -or $healthTimeout -lt 1 -or $healthTimeout -gt 300) { throw "Invalid health policy for ${name}." } if ($healthKind -eq 'http') { $parsedHealth = $null if (-not [Uri]::TryCreate($healthURL, [UriKind]::Absolute, [ref]$parsedHealth) -or $parsedHealth.Scheme -notin @('http', 'https')) { throw "Invalid health URL for ${name}." } if ($ports -notcontains $parsedHealth.Port) { throw "Health URL port is not declared for ${name}." } } $browserOrigin = [string](Get-CoordinationProperty -Object $item -Name 'browser_origin') if (-not [string]::IsNullOrWhiteSpace($browserOrigin)) { $parsedOrigin = $null if (-not [Uri]::TryCreate($browserOrigin, [UriKind]::Absolute, [ref]$parsedOrigin) -or $parsedOrigin.Scheme -notin @('http', 'https') -or $parsedOrigin.AbsolutePath -ne '/' -or $parsedOrigin.Query -or $parsedOrigin.Fragment) { throw "Invalid browser origin for ${name}." } if ($ports -notcontains $parsedOrigin.Port) { throw "Browser origin port is not declared for ${name}." } } $identities = @() foreach ($identity in @((Get-CoordinationProperty -Object $item -Name 'identities'))) { Assert-CoordinationProperties -Object $identity -Allowed @('principal', 'key_id', 'private_key_path') -Context "$name identity" $principal = [string](Get-CoordinationProperty -Object $identity -Name 'principal') $keyID = [string](Get-CoordinationProperty -Object $identity -Name 'key_id') if ($principal -notmatch "^yv:${name}:[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" -or $keyID -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') { throw "Invalid machine identity metadata for ${name}." } $keyPath = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $identity -Name 'private_key_path')) if (-not (Test-Path -LiteralPath $keyPath -PathType Leaf)) { throw "Machine identity key not found for ${name}." } $identities += [pscustomobject]@{ Principal = $principal; KeyID = $keyID; PrivateKeyPath = $keyPath } } $products += [pscustomobject]@{ Name = $name; Enabled = [bool]$rawEnabled; Version = $version PackageRoot = $packageRoot; EnvironmentFile = $environmentFile; Environment = Read-CoordinationEnvironment -Path $environmentFile DataDirectory = $dataDirectory; LogDirectory = $logDirectory; Ports = @($ports) BrowserOrigin = $browserOrigin; CookieName = [string](Get-CoordinationProperty -Object $item -Name 'cookie_name') AccountNamespace = [string](Get-CoordinationProperty -Object $item -Name 'account_namespace'); DatabaseID = [string](Get-CoordinationProperty -Object $item -Name 'database_id'); DatabaseRole = [string](Get-CoordinationProperty -Object $item -Name 'database_role') Start = $start; Stop = $stop; HealthKind = $healthKind; HealthURL = $healthURL; HealthTimeoutSeconds = $healthTimeout; Identities = @($identities) } } $result = [pscustomobject]@{ Path = $manifestPath; Digest = Get-CoordinationFileDigest -Path $manifestPath; DeploymentID = $deploymentID; RuntimeRoot = $runtimeRoot; Products = @($products); RepositoryRoot = Get-CoordinationRepositoryRoot } Assert-CoordinationIsolation -Configuration $result return $result } function Assert-CoordinationIsolation { param([Parameter(Mandatory = $true)]$Configuration) $packages = @($Configuration.Products | ForEach-Object { $_.PackageRoot }) $paths = @([pscustomobject]@{ Label = 'coordination runtime'; Path = $Configuration.RuntimeRoot }) foreach ($product in $Configuration.Products) { $paths += [pscustomobject]@{ Label = "$($product.Name) package"; Path = $product.PackageRoot } $paths += [pscustomobject]@{ Label = "$($product.Name) data"; Path = $product.DataDirectory } $paths += [pscustomobject]@{ Label = "$($product.Name) logs"; Path = $product.LogDirectory } Assert-CoordinationExternalSecretPath -Path $product.EnvironmentFile -RepositoryRoot $Configuration.RepositoryRoot -PackageRoots $packages foreach ($identity in $product.Identities) { Assert-CoordinationExternalSecretPath -Path $identity.PrivateKeyPath -RepositoryRoot $Configuration.RepositoryRoot -PackageRoots $packages } } Assert-CoordinationDistinctPaths -Entries $paths $ports = @{} $environmentFiles = @{} $identityKeys = @{} $privateKeyPaths = @{} foreach ($product in $Configuration.Products) { if ($environmentFiles.ContainsKey($product.EnvironmentFile.ToLowerInvariant())) { throw 'Products must not share an environment file.' } $environmentFiles[$product.EnvironmentFile.ToLowerInvariant()] = $true foreach ($port in $product.Ports) { if ($ports.ContainsKey($port)) { throw "Products must not share port $port." } $ports[$port] = $product.Name } foreach ($identity in $product.Identities) { $identityID = ($identity.Principal + '/' + $identity.KeyID).ToLowerInvariant() if ($identityKeys.ContainsKey($identityID)) { throw 'Machine principal/key pairs must be unique per product instance.' } $identityKeys[$identityID] = $true $privateKeyID = $identity.PrivateKeyPath.ToLowerInvariant() if ($privateKeyPaths.ContainsKey($privateKeyID)) { throw 'Machine identities must not share a private key file.' } $privateKeyPaths[$privateKeyID] = $true } } $sense = $Configuration.Products | Where-Object Name -eq 'sense' $bell = $Configuration.Products | Where-Object Name -eq 'bell' if ($sense.CookieName -ne 'Sense-Admin-Token' -or $bell.CookieName -ne 'Bell-Admin-Token' -or $sense.CookieName -eq $bell.CookieName) { throw 'Sense and Bell browser Cookie names are not isolated.' } if ([string]::IsNullOrWhiteSpace($sense.BrowserOrigin) -or [string]::IsNullOrWhiteSpace($bell.BrowserOrigin) -or $sense.BrowserOrigin -eq $bell.BrowserOrigin) { throw 'Sense and Bell browser origins must be distinct.' } foreach ($field in @('DatabaseID', 'DatabaseRole', 'AccountNamespace')) { if ([string]::IsNullOrWhiteSpace($sense.$field) -or [string]::IsNullOrWhiteSpace($bell.$field) -or $sense.$field -eq $bell.$field) { throw "Sense and Bell $field values must be non-empty and distinct." } } foreach ($required in @(@($sense, 'SENSE_DATABASE_URL', 'SENSE_JWT_SECRET'), @($bell, 'BELL_DATABASE_URL', 'BELL_JWT_SECRET'))) { $product, $databaseKey, $jwtKey = $required if (-not $product.Environment.ContainsKey($databaseKey) -or [string]::IsNullOrWhiteSpace([string]$product.Environment[$databaseKey])) { throw "$databaseKey is required in the external environment file." } if (-not $product.Environment.ContainsKey($jwtKey) -or ([string]$product.Environment[$jwtKey]).Length -lt 32) { throw "$jwtKey must contain at least 32 characters in the external environment file." } } if ([string]$sense.Environment['SENSE_DATABASE_URL'] -eq [string]$bell.Environment['BELL_DATABASE_URL']) { throw 'Sense and Bell must not share a database URL.' } if ([string]$sense.Environment['SENSE_JWT_SECRET'] -ceq [string]$bell.Environment['BELL_JWT_SECRET']) { throw 'Sense and Bell must not share a JWT secret.' } $senseDatabase = Get-CoordinationDatabaseIdentity -Connection ([string]$sense.Environment['SENSE_DATABASE_URL']) $bellDatabase = Get-CoordinationDatabaseIdentity -Connection ([string]$bell.Environment['BELL_DATABASE_URL']) if ($senseDatabase.Database -ne $sense.DatabaseID -or $senseDatabase.Role -ne $sense.DatabaseRole) { throw 'Sense database URL does not match its declared database and role.' } if ($bellDatabase.Database -ne $bell.DatabaseID -or $bellDatabase.Role -ne $bell.DatabaseRole) { throw 'Bell database URL does not match its declared database and role.' } foreach ($portRule in @(@($sense, 'SENSE_PORT', 0), @($bell, 'BELL_PORT', 0), @($bell, 'BELL_WEB_PORT', 1))) { $product, $key, $index = $portRule if (-not $product.Environment.ContainsKey($key) -or [int]$product.Environment[$key] -ne $product.Ports[[int]$index]) { throw "$key must match the declared product port." } } } function Resolve-CoordinationSelection { param([Parameter(Mandatory = $true)]$Configuration, [string[]]$Product = @('all'), [ValidateSet('start', 'stop', 'status')][string]$Operation = 'status') $requested = @() foreach ($entry in @($Product)) { $requested += @($entry -split ',') | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } } if ($requested.Count -eq 0 -or $requested -contains 'all') { $requested = if ($Operation -eq 'start') { @($Configuration.Products | Where-Object Enabled | ForEach-Object Name) } else { @($Configuration.Products | ForEach-Object Name) } } foreach ($name in $requested) { if ($name -notin $script:CoordinationProductNames) { throw "Unknown product selection: $name" } $target = $Configuration.Products | Where-Object Name -eq $name if ($Operation -eq 'start' -and -not $target.Enabled) { throw "Product is disabled in the manifest: $name" } } $order = if ($Operation -eq 'stop') { $script:CoordinationStopOrder } else { $script:CoordinationStartOrder } return @($order | Where-Object { $requested -contains $_ } | ForEach-Object { $name = $_; $Configuration.Products | Where-Object Name -eq $name }) } function Get-CoordinationStatePath { param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product) return Join-Path $Configuration.RuntimeRoot "state\$($Product.Name).json" } function Read-CoordinationState { param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product) $path = Get-CoordinationStatePath -Configuration $Configuration -Product $Product if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } try { return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Invalid coordination state for $($Product.Name)." } } function Test-CoordinationOwnedProcess { param([Parameter(Mandatory = $true)]$State) $process = Get-CimInstance Win32_Process -Filter "ProcessId = $([int]$State.pid)" -ErrorAction SilentlyContinue if (-not $process -or [string]::IsNullOrWhiteSpace([string]$process.ExecutablePath)) { return $false } $expected = [IO.Path]::GetFullPath([string]$State.launcher_executable) if (-not [IO.Path]::GetFullPath([string]$process.ExecutablePath).Equals($expected, [StringComparison]::OrdinalIgnoreCase)) { return $false } return ([string]$process.CommandLine).IndexOf([string]$State.command_token, [StringComparison]::OrdinalIgnoreCase) -ge 0 } function Test-CoordinationHealth { param([Parameter(Mandatory = $true)]$Product, [Parameter(Mandatory = $true)]$State) if (-not (Test-CoordinationOwnedProcess -State $State)) { return $false } if ($Product.HealthKind -eq 'process') { return $true } try { $response = Invoke-WebRequest -Uri $Product.HealthURL -Method Get -TimeoutSec 3 -UseBasicParsing return $response.StatusCode -ge 200 -and $response.StatusCode -lt 400 } catch { return $false } } function Wait-CoordinationHealth { param([Parameter(Mandatory = $true)]$Product, [Parameter(Mandatory = $true)]$State) if ($Product.HealthKind -eq 'process') { Start-Sleep -Milliseconds 750 if (Test-CoordinationHealth -Product $Product -State $State) { return } throw "$($Product.Name) exited during the process health grace period." } $deadline = [DateTime]::UtcNow.AddSeconds($Product.HealthTimeoutSeconds) do { if (Test-CoordinationHealth -Product $Product -State $State) { return } if (-not (Get-Process -Id ([int]$State.pid) -ErrorAction SilentlyContinue)) { throw "$($Product.Name) exited before becoming healthy." } Start-Sleep -Milliseconds 250 } while ([DateTime]::UtcNow -lt $deadline) throw "$($Product.Name) did not become healthy before the timeout." } function Assert-CoordinationPortsAvailable { param([Parameter(Mandatory = $true)]$Product) foreach ($port in $Product.Ports) { if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { throw "$($Product.Name) port $port is already in use." } } } function Start-CoordinationProduct { param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product) $existing = Read-CoordinationState -Configuration $Configuration -Product $Product if ($existing -and (Test-CoordinationOwnedProcess -State $existing)) { if ([string]$existing.manifest_sha256 -ne $Configuration.Digest) { throw "$($Product.Name) is running from a different manifest revision." } if (Test-CoordinationHealth -Product $Product -State $existing) { Write-Host "$($Product.Name) is already running."; return } throw "$($Product.Name) has an owned but unhealthy process. Stop it before restart." } Assert-CoordinationPortsAvailable -Product $Product New-Item -ItemType Directory -Force -Path $Configuration.RuntimeRoot,(Join-Path $Configuration.RuntimeRoot 'state'),$Product.DataDirectory,$Product.LogDirectory | Out-Null $launcher = Get-CoordinationLauncher -Command $Product.Start $argumentLine = (@($launcher.Arguments) | ForEach-Object { ConvertTo-CoordinationArgument -Value ([string]$_) }) -join ' ' $stdout = Join-Path $Product.LogDirectory 'coordination.out.log' $stderr = Join-Path $Product.LogDirectory 'coordination.err.log' $process = Invoke-CoordinationEnvironment -Values $Product.Environment -Action { Start-Process -FilePath $launcher.Executable -ArgumentList $argumentLine -WorkingDirectory $Product.PackageRoot -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru } $state = [ordered]@{ schema_version = 'yovision.coordination-state/v1'; deployment_id = $Configuration.DeploymentID; product = $Product.Name pid = $process.Id; started_at = [DateTime]::UtcNow.ToString('o'); version = $Product.Version; manifest_sha256 = $Configuration.Digest launcher_executable = [IO.Path]::GetFullPath($launcher.Executable); command_token = $launcher.CommandToken; package_root = $Product.PackageRoot } $statePath = Get-CoordinationStatePath -Configuration $Configuration -Product $Product [IO.File]::WriteAllText($statePath, ($state | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) try { Wait-CoordinationHealth -Product $Product -State ([pscustomobject]$state) Write-Host "$($Product.Name) started (version $($Product.Version))." } catch { if (Test-CoordinationOwnedProcess -State ([pscustomobject]$state)) { & taskkill.exe /PID $process.Id /T /F 2>$null | Out-Null } Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue throw } } function Stop-CoordinationProduct { param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product) $statePath = Get-CoordinationStatePath -Configuration $Configuration -Product $Product $state = Read-CoordinationState -Configuration $Configuration -Product $Product if (-not $state) { Write-Host "$($Product.Name) is stopped."; return } if (-not (Test-CoordinationOwnedProcess -State $state)) { throw "$($Product.Name) state is stale or belongs to another process; no process was stopped." } if ($Product.Stop) { $stopLauncher = Get-CoordinationLauncher -Command $Product.Stop $stopArguments = (@($stopLauncher.Arguments) | ForEach-Object { ConvertTo-CoordinationArgument -Value ([string]$_) }) -join ' ' $stopEnvironment = @{} foreach ($name in $Product.Environment.Keys) { $stopEnvironment[$name] = $Product.Environment[$name] } $stopEnvironment['YOVISION_COORDINATION_OWNED_PID'] = [string]$state.pid $stopProcess = Invoke-CoordinationEnvironment -Values $stopEnvironment -Action { Start-Process -FilePath $stopLauncher.Executable -ArgumentList $stopArguments -WorkingDirectory $Product.PackageRoot -WindowStyle Hidden -Wait -PassThru } if ($stopProcess.ExitCode -ne 0) { throw "$($Product.Name) stop entrypoint failed with exit code $($stopProcess.ExitCode)." } } $deadline = [DateTime]::UtcNow.AddSeconds(10) while ((Test-CoordinationOwnedProcess -State $state) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 200 } if (Test-CoordinationOwnedProcess -State $state) { & taskkill.exe /PID ([int]$state.pid) /T /F | Out-Null } if (Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue) { throw "$($Product.Name) owned process did not stop." } Remove-Item -LiteralPath $statePath -Force Write-Host "$($Product.Name) stopped." } function Get-CoordinationProductStatus { param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product) $state = Read-CoordinationState -Configuration $Configuration -Product $Product if (-not $state) { return [pscustomobject]@{ Product = $Product.Name; Status = 'stopped'; PID = ''; Version = $Product.Version; Health = 'not-running' } } if (-not (Test-CoordinationOwnedProcess -State $state)) { return [pscustomobject]@{ Product = $Product.Name; Status = 'stale'; PID = $state.pid; Version = $state.version; Health = 'ownership-mismatch' } } if ([string]$state.manifest_sha256 -ne $Configuration.Digest) { return [pscustomobject]@{ Product = $Product.Name; Status = 'stale'; PID = $state.pid; Version = $state.version; Health = 'manifest-drift' } } $healthy = Test-CoordinationHealth -Product $Product -State $state return [pscustomobject]@{ Product = $Product.Name; Status = $(if ($healthy) { 'running' } else { 'unhealthy' }); PID = $state.pid; Version = $state.version; Health = $(if ($healthy) { 'ok' } else { 'failed' }) } }