From e2f7183ecf5e408d31c45cfa076236e4d274db7b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 16:08:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E4=B8=89?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=8F=AF=E9=80=89=E9=83=A8=E7=BD=B2=E7=BC=96?= =?UTF-8?q?=E6=8E=92=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/coordination/README.md | 40 ++ deploy/coordination/coordination.example.json | 66 +++ deploy/coordination/coordination.schema.json | 76 ++++ .../coordination/tests/coordination-smoke.ps1 | 166 +++++++ .../coordination/coordination-common.ps1 | 414 ++++++++++++++++++ .../runtime/coordination/start-yovision.bat | 3 + .../runtime/coordination/start-yovision.ps1 | 17 + .../runtime/coordination/status-yovision.bat | 3 + .../runtime/coordination/status-yovision.ps1 | 18 + .../runtime/coordination/stop-yovision.bat | 3 + .../runtime/coordination/stop-yovision.ps1 | 15 + 11 files changed, 821 insertions(+) create mode 100644 deploy/coordination/README.md create mode 100644 deploy/coordination/coordination.example.json create mode 100644 deploy/coordination/coordination.schema.json create mode 100644 deploy/coordination/tests/coordination-smoke.ps1 create mode 100644 scripts/runtime/coordination/coordination-common.ps1 create mode 100644 scripts/runtime/coordination/start-yovision.bat create mode 100644 scripts/runtime/coordination/start-yovision.ps1 create mode 100644 scripts/runtime/coordination/status-yovision.bat create mode 100644 scripts/runtime/coordination/status-yovision.ps1 create mode 100644 scripts/runtime/coordination/stop-yovision.bat create mode 100644 scripts/runtime/coordination/stop-yovision.ps1 diff --git a/deploy/coordination/README.md b/deploy/coordination/README.md new file mode 100644 index 0000000..a64bc0e --- /dev/null +++ b/deploy/coordination/README.md @@ -0,0 +1,40 @@ +# YoVision 可选协调部署 + +本目录只组合已经验收的 Sense、Brain、Bell 交付入口,不复制产品实现,也不成为业务事实源。三个产品仍使用独立包、进程、端口、数据目录、日志、数据库角色、账户空间、浏览器 Cookie 和机器身份。 + +## 准备 + +1. 将 `coordination.example.json` 复制到仓库外受控配置目录,填写三个已审核交付包的精确版本与绝对路径。 +2. 分别准备仓库外 `sense.env`、`brain.env`、`bell.env`。Sense 至少提供 `SENSE_DATABASE_URL`、`SENSE_JWT_SECRET`、`SENSE_PORT`,Bell 至少提供 `BELL_DATABASE_URL`、`BELL_JWT_SECRET`、`BELL_PORT`、`BELL_WEB_PORT`;其余配置(包括 connector 开关)仍遵循各产品自己的环境文件说明。不得在清单或仓库中填写密码、JWT、token 或私钥内容。 +3. 为每个调用实例创建独立 Ed25519 私钥和消费者公钥注册表;清单只引用私钥路径。 +4. 为 Sense、Bell 创建不同 PostgreSQL 数据库和角色;先使用各自迁移入口完成备份与迁移。 +5. 确认清单声明的所有端口、包目录、数据目录和日志目录互不重叠。 + +编排器会创建并隔离清单中的数据、日志目录;各产品环境文件或产品配置还必须把自身持久化与业务日志指向对应目录。编排器不会猜测或改写产品内部配置。 + +示例清单中的地址、版本和标识都是不可直接投产的占位值。默认 16 路只是当前交付配额,不是编排器容量上限。 + +## 命令 + +从仓库根目录运行;不需要修改 PowerShell 执行策略: + +```powershell +pwsh -NoProfile -File scripts/runtime/coordination/start-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -ValidateOnly +pwsh -NoProfile -File scripts/runtime/coordination/start-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product bell,sense,brain +pwsh -NoProfile -File scripts/runtime/coordination/status-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product all +pwsh -NoProfile -File scripts/runtime/coordination/stop-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product brain +``` + +`.bat` 包装器接受相同参数。启动时,`all` 只包含清单中 `enabled=true` 的产品;停止和查看状态时,`all` 会覆盖三个产品,避免产品被停用后遗留进程。启动顺序固定为 Bell → Sense → Brain,停止顺序固定为 Brain → Sense → Bell。单端失败只返回非零并清理该端新进程,不自动停止已经运行的其他端。 + +## 状态与日志 + +编排状态写入清单的 `runtime_root\state`,每端只保存 PID、启动时间、版本、命令路径和清单摘要,不保存环境变量值。协调启动日志分别写入各产品独立 `log_directory`。产品自己的日志仍由产品入口管理。 + +`status` 返回 `running`、`unhealthy`、`stopped` 或 `stale`;仅当所选产品全部健康运行时退出码为 0。PID 与命令归属不匹配时不停止进程,必须人工核对。 + +## 升级与回退 + +升级顺序为:备份 Sense/Bell → 迁移 Bell → 启动/检查 Bell → 迁移 Sense → 启动/检查 Sense → 更新 Brain → 启用 connector。每次只替换一个独立包并更新清单版本。 + +回退时先停用 Brain event export、Sense ingress/relay 与 Bell ingress/evidence connector,再按产品独立入口回退包或恢复各自数据库。不得删除 Sense Outbox、运行投影、Bell Receipt/Event、replay 或审计事实。根级编排故障时直接恢复三个产品原有独立入口。 diff --git a/deploy/coordination/coordination.example.json b/deploy/coordination/coordination.example.json new file mode 100644 index 0000000..d57f2cf --- /dev/null +++ b/deploy/coordination/coordination.example.json @@ -0,0 +1,66 @@ +{ + "schema_version": "yovision.coordination/v1", + "deployment_id": "school-a-yovision", + "runtime_root": "C:\\YoVision\\runtime\\coordination", + "products": { + "sense": { + "enabled": true, + "version": "replace-with-reviewed-sense-artifact-version", + "package_root": "C:\\YoVision\\packages\\sense", + "environment_file": "C:\\YoVision\\secrets\\sense.env", + "data_directory": "C:\\YoVision\\data\\sense", + "log_directory": "C:\\YoVision\\logs\\sense", + "ports": [18080, 9997, 8889], + "browser_origin": "http://127.0.0.1:18080", + "cookie_name": "Sense-Admin-Token", + "account_namespace": "sense-users", + "database_id": "sense", + "database_role": "sense_app", + "start": { "executable": "scripts\\runtime\\start-sense.ps1", "arguments": ["-Mode", "production"] }, + "stop": { "executable": "scripts\\runtime\\stop-sense.ps1", "arguments": ["-Mode", "production"] }, + "health": { "kind": "http", "url": "http://127.0.0.1:18080/healthz", "timeout_seconds": 60 }, + "identities": [ + { "principal": "yv:sense:school-a", "key_id": "sense-2026-01", "private_key_path": "C:\\YoVision\\secrets\\sense-to-bell.ed25519" } + ] + }, + "brain": { + "enabled": true, + "version": "replace-with-reviewed-brain-artifact-version", + "package_root": "C:\\YoVision\\packages\\brain", + "environment_file": "C:\\YoVision\\secrets\\brain.env", + "data_directory": "C:\\YoVision\\data\\brain", + "log_directory": "C:\\YoVision\\logs\\brain", + "ports": [18100], + "browser_origin": "", + "cookie_name": "", + "account_namespace": "brain-machine-only", + "database_id": "", + "database_role": "", + "start": { "executable": ".venv\\Scripts\\python.exe", "arguments": ["-m", "yovision_brain.app", "--config", "C:\\YoVision\\config\\brain.json", "--output", "-"] }, + "health": { "kind": "process", "timeout_seconds": 15 }, + "identities": [ + { "principal": "yv:brain:school-a", "key_id": "brain-2026-01", "private_key_path": "C:\\YoVision\\secrets\\brain-to-sense.ed25519" } + ] + }, + "bell": { + "enabled": true, + "version": "replace-with-reviewed-bell-artifact-version", + "package_root": "C:\\YoVision\\packages\\bell", + "environment_file": "C:\\YoVision\\secrets\\bell.env", + "data_directory": "C:\\YoVision\\data\\bell", + "log_directory": "C:\\YoVision\\logs\\bell", + "ports": [18090, 18091], + "browser_origin": "http://127.0.0.1:18091", + "cookie_name": "Bell-Admin-Token", + "account_namespace": "bell-users", + "database_id": "bell", + "database_role": "bell_app", + "start": { "executable": "scripts\\runtime\\start-bell.ps1", "arguments": [] }, + "stop": { "executable": "scripts\\runtime\\stop-bell.ps1", "arguments": [] }, + "health": { "kind": "http", "url": "http://127.0.0.1:18090/healthz", "timeout_seconds": 60 }, + "identities": [ + { "principal": "yv:bell:school-a", "key_id": "bell-2026-01", "private_key_path": "C:\\YoVision\\secrets\\bell-to-sense.ed25519" } + ] + } + } +} diff --git a/deploy/coordination/coordination.schema.json b/deploy/coordination/coordination.schema.json new file mode 100644 index 0000000..0b59c75 --- /dev/null +++ b/deploy/coordination/coordination.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://yovision.local/schemas/coordination/v1", + "title": "YoVision coordination deployment manifest v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "deployment_id", "runtime_root", "products"], + "properties": { + "schema_version": { "const": "yovision.coordination/v1" }, + "deployment_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$" }, + "runtime_root": { "type": "string", "minLength": 1 }, + "products": { + "type": "object", + "additionalProperties": false, + "required": ["sense", "brain", "bell"], + "properties": { + "sense": { "$ref": "#/$defs/product" }, + "brain": { "$ref": "#/$defs/product" }, + "bell": { "$ref": "#/$defs/product" } + } + } + }, + "$defs": { + "command": { + "type": "object", + "additionalProperties": false, + "required": ["executable", "arguments"], + "properties": { + "executable": { "type": "string", "minLength": 1 }, + "arguments": { "type": "array", "items": { "type": "string" } } + } + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": ["principal", "key_id", "private_key_path"], + "properties": { + "principal": { "type": "string", "pattern": "^yv:(sense|brain|bell):[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, + "key_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, + "private_key_path": { "type": "string", "minLength": 1 } + } + }, + "product": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "version", "package_root", "environment_file", "data_directory", "log_directory", "ports", "browser_origin", "cookie_name", "account_namespace", "database_id", "database_role", "start", "health", "identities"], + "properties": { + "enabled": { "type": "boolean" }, + "version": { "type": "string", "minLength": 1 }, + "package_root": { "type": "string", "minLength": 1 }, + "environment_file": { "type": "string", "minLength": 1 }, + "data_directory": { "type": "string", "minLength": 1 }, + "log_directory": { "type": "string", "minLength": 1 }, + "ports": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 65535 }, "uniqueItems": true }, + "browser_origin": { "type": "string" }, + "cookie_name": { "type": "string" }, + "account_namespace": { "type": "string", "minLength": 1 }, + "database_id": { "type": "string" }, + "database_role": { "type": "string" }, + "start": { "$ref": "#/$defs/command" }, + "stop": { "$ref": "#/$defs/command" }, + "health": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "timeout_seconds"], + "properties": { + "kind": { "enum": ["process", "http"] }, + "url": { "type": "string" }, + "timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 300 } + } + }, + "identities": { "type": "array", "items": { "$ref": "#/$defs/identity" } } + } + } + } +} diff --git a/deploy/coordination/tests/coordination-smoke.ps1 b/deploy/coordination/tests/coordination-smoke.ps1 new file mode 100644 index 0000000..3b2376f --- /dev/null +++ b/deploy/coordination/tests/coordination-smoke.ps1 @@ -0,0 +1,166 @@ +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +function Assert-True { + param([Parameter(Mandatory = $true)][bool]$Condition, [Parameter(Mandatory = $true)][string]$Message) + if (-not $Condition) { throw $Message } +} + +function Get-FreePort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } +} + +function Write-Utf8File { + param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content) + $directory = Split-Path -Parent $Path + if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } + [IO.File]::WriteAllText($Path, $Content, [Text.UTF8Encoding]::new($false)) +} + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) +$scriptsRoot = Join-Path $repositoryRoot 'scripts\runtime\coordination' +$startScript = Join-Path $scriptsRoot 'start-yovision.ps1' +$stopScript = Join-Path $scriptsRoot 'stop-yovision.ps1' +$statusScript = Join-Path $scriptsRoot 'status-yovision.ps1' +$tempParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$testRoot = Join-Path $tempParent ("yovision coordination-" + [guid]::NewGuid().ToString('N')) +$manifestPath = Join-Path $testRoot 'config\coordination.json' +$manifest = $null + +try { + New-Item -ItemType Directory -Force -Path $testRoot,(Join-Path $testRoot 'markers') | Out-Null + $ports = @{ sense = Get-FreePort; brain = Get-FreePort; bell = Get-FreePort; bellWeb = Get-FreePort } + Assert-True (($ports.Values | Select-Object -Unique).Count -eq 4) 'Dynamic test ports are not unique.' + $products = [ordered]@{} + foreach ($name in @('sense', 'brain', 'bell')) { + $packageRoot = Join-Path $testRoot "packages\$name" + $start = Join-Path $packageRoot 'start.ps1' + $stop = Join-Path $packageRoot 'stop.ps1' + Write-Utf8File -Path $start -Content @' +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +if ($env:FAKE_STARTED_FILE) { [IO.File]::WriteAllText($env:FAKE_STARTED_FILE, $PID.ToString(), [Text.UTF8Encoding]::new($false)) } +while ($true) { Start-Sleep -Milliseconds 250 } +'@ + Write-Utf8File -Path $stop -Content @' +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +if ($env:FAKE_STOPPED_FILE) { [IO.File]::WriteAllText($env:FAKE_STOPPED_FILE, 'stopped', [Text.UTF8Encoding]::new($false)) } +$ownedPID = 0 +if (-not [int]::TryParse($env:YOVISION_COORDINATION_OWNED_PID, [ref]$ownedPID)) { exit 4 } +& taskkill.exe /PID $ownedPID /T /F 2>$null | Out-Null +exit 0 +'@ + $environmentPath = Join-Path $testRoot "secrets\$name.env" + $environment = "FAKE_STARTED_FILE=$(Join-Path $testRoot "markers\$name.started")`nFAKE_STOPPED_FILE=$(Join-Path $testRoot "markers\$name.stopped")`n" + if ($name -eq 'sense') { $environment += "SENSE_DATABASE_URL=postgres://sense_role@127.0.0.1/sense_db`nSENSE_JWT_SECRET=$(('s' * 40))`nSENSE_PORT=$($ports.sense)`n" } + if ($name -eq 'bell') { $environment += "BELL_DATABASE_URL=postgres://bell_role@127.0.0.1/bell_db`nBELL_JWT_SECRET=$(('b' * 40))`nBELL_PORT=$($ports.bell)`nBELL_WEB_PORT=$($ports.bellWeb)`n" } + Write-Utf8File -Path $environmentPath -Content $environment + $keyPath = Join-Path $testRoot "secrets\$name.ed25519" + Write-Utf8File -Path $keyPath -Content ([guid]::NewGuid().ToString('N')) + $productPorts = if ($name -eq 'sense') { @($ports.sense) } elseif ($name -eq 'bell') { @($ports.bell, $ports.bellWeb) } else { @($ports.brain) } + $products[$name] = [ordered]@{ + enabled = $true; version = "test-$name-v1"; package_root = $packageRoot; environment_file = $environmentPath + data_directory = (Join-Path $testRoot "data\$name"); log_directory = (Join-Path $testRoot "logs\$name"); ports = $productPorts + browser_origin = $(if ($name -eq 'sense') { "http://127.0.0.1:$($ports.sense)" } elseif ($name -eq 'bell') { "http://127.0.0.1:$($ports.bellWeb)" } else { '' }) + cookie_name = $(if ($name -eq 'sense') { 'Sense-Admin-Token' } elseif ($name -eq 'bell') { 'Bell-Admin-Token' } else { '' }) + account_namespace = "$name-accounts"; database_id = $(if ($name -eq 'brain') { '' } else { "${name}_db" }); database_role = $(if ($name -eq 'brain') { '' } else { "${name}_role" }) + start = [ordered]@{ executable = 'start.ps1'; arguments = @() }; stop = [ordered]@{ executable = 'stop.ps1'; arguments = @() } + health = [ordered]@{ kind = 'process'; timeout_seconds = 5 } + identities = @([ordered]@{ principal = "yv:${name}:test"; key_id = "${name}-test-key"; private_key_path = $keyPath }) + } + } + $manifest = [ordered]@{ schema_version = 'yovision.coordination/v1'; deployment_id = 'test-coordination'; runtime_root = (Join-Path $testRoot 'runtime'); products = $products } + Write-Utf8File -Path $manifestPath -Content ($manifest | ConvertTo-Json -Depth 20) + + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -ValidateOnly + Assert-True ($LASTEXITCODE -eq 0) 'Manifest validation failed.' + + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product sense + Assert-True ($LASTEXITCODE -eq 0) 'Sense-only start failed.' + & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product sense -Json + Assert-True ($LASTEXITCODE -eq 0) 'Sense-only status is not healthy.' + & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json + Assert-True ($LASTEXITCODE -eq 3) 'Stopped Bell status did not return exit code 3.' + & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product sense + Assert-True ($LASTEXITCODE -eq 0) 'Sense-only stop failed.' + + $occupiedPort = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $ports.sense) + try { + $occupiedPort.Start() + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product sense 2>$null + Assert-True ($LASTEXITCODE -eq 1) 'An occupied Sense port was not rejected.' + Assert-True (-not (Test-Path -LiteralPath (Join-Path $testRoot 'runtime\state\sense.json'))) 'Occupied-port failure left Sense state behind.' + } finally { + $occupiedPort.Stop() + } + + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product brain,bell + Assert-True ($LASTEXITCODE -eq 0) 'Brain+Bell combination start failed.' + $bellStatePath = Join-Path $testRoot 'runtime\state\bell.json' + $bellStateText = Get-Content -LiteralPath $bellStatePath -Raw -Encoding UTF8 + $foreignState = $bellStateText | ConvertFrom-Json + $foreignState.pid = $PID + Write-Utf8File -Path $bellStatePath -Content ($foreignState | ConvertTo-Json -Depth 10) + $ownershipStatus = & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json + Assert-True ($LASTEXITCODE -eq 3 -and ($ownershipStatus -join "`n") -match 'ownership-mismatch') 'Foreign PID ownership was not diagnosed.' + & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product bell 2>$null + Assert-True ($LASTEXITCODE -eq 1 -and $null -ne (Get-Process -Id $PID -ErrorAction SilentlyContinue)) 'Stop did not protect an unrelated process.' + Write-Utf8File -Path $bellStatePath -Content $bellStateText + + $manifestText = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 + Write-Utf8File -Path $manifestPath -Content ($manifestText + "`n") + $driftStatus = & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json + Assert-True ($LASTEXITCODE -eq 3 -and ($driftStatus -join "`n") -match 'manifest-drift') 'Manifest drift was not diagnosed.' + & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product bell + Assert-True ($LASTEXITCODE -eq 0) 'Owned Bell could not be stopped after manifest drift.' + Write-Utf8File -Path $manifestPath -Content $manifestText + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product bell + Assert-True ($LASTEXITCODE -eq 0) 'Bell restart after manifest restoration failed.' + + & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product brain + Assert-True ($LASTEXITCODE -eq 0) 'Brain-only stop failed.' + & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json + Assert-True ($LASTEXITCODE -eq 0) 'Stopping Brain damaged Bell.' + + Write-Utf8File -Path (Join-Path $testRoot 'packages\sense\start.ps1') -Content 'exit 7' + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product sense 2>$null + Assert-True ($LASTEXITCODE -eq 1) 'A failing product start did not return exit code 1.' + & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json + Assert-True ($LASTEXITCODE -eq 0) 'A Sense start failure damaged Bell.' + + $badManifest = $manifest | ConvertTo-Json -Depth 20 | ConvertFrom-Json -Depth 20 + $badManifest.products.bell.database_id = $badManifest.products.sense.database_id + $badPath = Join-Path $testRoot 'config\invalid-isolation.json' + Write-Utf8File -Path $badPath -Content ($badManifest | ConvertTo-Json -Depth 20) + & pwsh.exe -NoProfile -File $startScript -Manifest $badPath -ValidateOnly 2>$null + Assert-True ($LASTEXITCODE -eq 1) 'Shared database identity was not rejected.' + + $badPortManifest = $manifest | ConvertTo-Json -Depth 20 | ConvertFrom-Json -Depth 20 + $badPortManifest.products.bell.ports[0] = $badPortManifest.products.sense.ports[0] + $badBellEnvironmentPath = Join-Path $testRoot 'secrets\bell-duplicate-port.env' + $badBellEnvironment = Get-Content -LiteralPath $badPortManifest.products.bell.environment_file -Raw -Encoding UTF8 + $badBellEnvironment = $badBellEnvironment -replace "(?m)^BELL_PORT=\d+$", "BELL_PORT=$($ports.sense)" + Write-Utf8File -Path $badBellEnvironmentPath -Content $badBellEnvironment + $badPortManifest.products.bell.environment_file = $badBellEnvironmentPath + $badPortPath = Join-Path $testRoot 'config\invalid-port-isolation.json' + Write-Utf8File -Path $badPortPath -Content ($badPortManifest | ConvertTo-Json -Depth 20) + & pwsh.exe -NoProfile -File $startScript -Manifest $badPortPath -ValidateOnly 2>$null + Assert-True ($LASTEXITCODE -eq 1) 'Shared product port was not rejected.' + + & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product all + Assert-True ($LASTEXITCODE -eq 0) 'Final stop failed.' + & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product all -Json + Assert-True ($LASTEXITCODE -eq 3) 'Stopped combination did not report non-running status.' + foreach ($name in @('sense', 'brain', 'bell')) { + $statePath = Join-Path $testRoot "runtime\state\$name.json" + Assert-True (-not (Test-Path -LiteralPath $statePath)) "State was not cleaned for $name." + } + Write-Host 'Coordination smoke passed: validation, port isolation, independent start/stop, combination, failure isolation, ownership and cleanup.' + exit 0 +} finally { + if ($manifest -and (Test-Path -LiteralPath $manifestPath)) { & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product all 2>$null | Out-Null } + $resolved = [IO.Path]::GetFullPath($testRoot) + if ($resolved.StartsWith($tempParent, [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $resolved)) { Remove-Item -LiteralPath $resolved -Recurse -Force } +} diff --git a/scripts/runtime/coordination/coordination-common.ps1 b/scripts/runtime/coordination/coordination-common.ps1 new file mode 100644 index 0000000..e4b7560 --- /dev/null +++ b/scripts/runtime/coordination/coordination-common.ps1 @@ -0,0 +1,414 @@ +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' }) } +} diff --git a/scripts/runtime/coordination/start-yovision.bat b/scripts/runtime/coordination/start-yovision.bat new file mode 100644 index 0000000..378cebd --- /dev/null +++ b/scripts/runtime/coordination/start-yovision.bat @@ -0,0 +1,3 @@ +@echo off +pwsh.exe -NoProfile -File "%~dp0start-yovision.ps1" %* +exit /b %ERRORLEVEL% diff --git a/scripts/runtime/coordination/start-yovision.ps1 b/scripts/runtime/coordination/start-yovision.ps1 new file mode 100644 index 0000000..6b555a4 --- /dev/null +++ b/scripts/runtime/coordination/start-yovision.ps1 @@ -0,0 +1,17 @@ +param( + [Parameter(Mandatory = $true)][string]$Manifest, + [string[]]$Product = @('all'), + [switch]$ValidateOnly +) +. (Join-Path $PSScriptRoot 'coordination-common.ps1') + +try { + $configuration = Read-CoordinationManifest -Manifest $Manifest + $selection = Resolve-CoordinationSelection -Configuration $configuration -Product $Product -Operation start + if ($ValidateOnly) { Write-Host "Coordination manifest is valid for: $(($selection.Name) -join ', ')."; exit 0 } + foreach ($item in $selection) { Start-CoordinationProduct -Configuration $configuration -Product $item } + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/scripts/runtime/coordination/status-yovision.bat b/scripts/runtime/coordination/status-yovision.bat new file mode 100644 index 0000000..1975a71 --- /dev/null +++ b/scripts/runtime/coordination/status-yovision.bat @@ -0,0 +1,3 @@ +@echo off +pwsh.exe -NoProfile -File "%~dp0status-yovision.ps1" %* +exit /b %ERRORLEVEL% diff --git a/scripts/runtime/coordination/status-yovision.ps1 b/scripts/runtime/coordination/status-yovision.ps1 new file mode 100644 index 0000000..ed03ce8 --- /dev/null +++ b/scripts/runtime/coordination/status-yovision.ps1 @@ -0,0 +1,18 @@ +param( + [Parameter(Mandatory = $true)][string]$Manifest, + [string[]]$Product = @('all'), + [switch]$Json +) +. (Join-Path $PSScriptRoot 'coordination-common.ps1') + +try { + $configuration = Read-CoordinationManifest -Manifest $Manifest + $selection = Resolve-CoordinationSelection -Configuration $configuration -Product $Product -Operation status + $result = @($selection | ForEach-Object { Get-CoordinationProductStatus -Configuration $configuration -Product $_ }) + if ($Json) { $result | ConvertTo-Json -Depth 4 } else { $result | Format-Table -AutoSize } + if (@($result | Where-Object Status -ne 'running').Count -gt 0) { exit 3 } + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/scripts/runtime/coordination/stop-yovision.bat b/scripts/runtime/coordination/stop-yovision.bat new file mode 100644 index 0000000..c6d6a3b --- /dev/null +++ b/scripts/runtime/coordination/stop-yovision.bat @@ -0,0 +1,3 @@ +@echo off +pwsh.exe -NoProfile -File "%~dp0stop-yovision.ps1" %* +exit /b %ERRORLEVEL% diff --git a/scripts/runtime/coordination/stop-yovision.ps1 b/scripts/runtime/coordination/stop-yovision.ps1 new file mode 100644 index 0000000..e9e466d --- /dev/null +++ b/scripts/runtime/coordination/stop-yovision.ps1 @@ -0,0 +1,15 @@ +param( + [Parameter(Mandatory = $true)][string]$Manifest, + [string[]]$Product = @('all') +) +. (Join-Path $PSScriptRoot 'coordination-common.ps1') + +try { + $configuration = Read-CoordinationManifest -Manifest $Manifest + $selection = Resolve-CoordinationSelection -Configuration $configuration -Product $Product -Operation stop + foreach ($item in $selection) { Stop-CoordinationProduct -Configuration $configuration -Product $item } + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} -- 2.34.1 From 504dd1a2e925525be02ff808566e22ff75add52d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 16:12:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E4=B8=89?= =?UTF-8?q?=E7=AB=AF=E7=8B=AC=E7=AB=8B=E7=BC=96=E6=8E=92=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/coordination/tests/coordination-smoke.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/deploy/coordination/tests/coordination-smoke.ps1 b/deploy/coordination/tests/coordination-smoke.ps1 index 3b2376f..f066b6d 100644 --- a/deploy/coordination/tests/coordination-smoke.ps1 +++ b/deploy/coordination/tests/coordination-smoke.ps1 @@ -96,6 +96,15 @@ exit 0 $occupiedPort.Stop() } + foreach ($standaloneProduct in @('brain', 'bell')) { + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product $standaloneProduct + Assert-True ($LASTEXITCODE -eq 0) "$standaloneProduct standalone start failed." + & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product $standaloneProduct -Json + Assert-True ($LASTEXITCODE -eq 0) "$standaloneProduct standalone status is not healthy." + & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product $standaloneProduct + Assert-True ($LASTEXITCODE -eq 0) "$standaloneProduct standalone stop failed." + } + & pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product brain,bell Assert-True ($LASTEXITCODE -eq 0) 'Brain+Bell combination start failed.' $bellStatePath = Join-Path $testRoot 'runtime\state\bell.json' -- 2.34.1