415 lines
29 KiB
PowerShell
415 lines
29 KiB
PowerShell
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' }) }
|
|
}
|