Compare commits

...
Author SHA1 Message Date
QiuSW c2b2943a3a test: 增加三项目协调隔离 E2E (#155) 2026-08-31 17:34:37 +08:00
ila 55b12df373 docs: 同步根级部署编排说明 (#168)
用户于 2026-08-31 明确验收通过 #168。
2026-08-31 16:57:34 +08:00
QiuSW 27d465c250 docs: 同步根级部署编排说明 (#168) 2026-08-31 16:32:48 +08:00
ila 4a2c4aa638 feat: 建立三项目可选部署编排 (#154)
用户于 2026-08-31 明确验收通过 #154。
2026-08-31 16:16:49 +08:00
QiuSW 504dd1a2e9 test: 补充三端独立编排验证 (#154) 2026-08-31 16:12:14 +08:00
QiuSW e2f7183ecf feat: 建立三项目可选部署编排 (#154) 2026-08-31 16:08:05 +08:00
ila eb4e1a9ea1 merge: 同步 connector 验收文档 (#152 #153)
同步已验收 #152/#153 的 Wiki 核心镜像。
2026-08-31 15:40:54 +08:00
18 changed files with 1150 additions and 8 deletions
+40
View File
@@ -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 或审计事实。根级编排故障时直接恢复三个产品原有独立入口。
@@ -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" }
]
}
}
}
@@ -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" } }
}
}
}
}
@@ -0,0 +1,175 @@
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()
}
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'
$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 }
}
+25 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: b25498f58c690f787f0b572788617ef6b0d8ea7d
synchronized_at: 2026-08-31T07:23:02Z
wiki_revision: 77af1bb5a71d7b89e74b1905ca11b6040dc0d1eb
synchronized_at: 2026-08-31T08:25:26Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -359,3 +359,26 @@ v1 使用 HTTPS 上的 Ed25519 短期请求绑定 JWS。每个部署实例拥有
Sense/Bell 使用 Go 标准库 Ed25519,Brain 冻结 `cryptography==50.0.1`。固定跨语言向量证明 Go/Python 可互相验签。#151 只提供身份、注册表、传输策略及可注入 replay 接口;#152/#153 才注册业务 endpoint,并必须使用各产品独立的持久原子 replay store验证重启,不能共享数据库。
<!-- machine-identity-v1:end -->
<!-- coordination-deployment-v1:start -->
## 可选协调部署层
工单 #154 已于 2026-08-31 验收。根级协调部署层位于 `deploy/coordination/**` 与 `scripts/runtime/coordination/**`,它只负责声明、校验和调用三个独立产品入口:
```text
仓库外 coordination.json
├─ Sense 独立包 / env / DB / 端口 / 数据 / 日志 / 机器身份
├─ Brain 独立包 / env / 端口 / 数据 / 日志 / 机器身份
└─ Bell 独立包 / env / DB / 端口 / 数据 / 日志 / 机器身份
↓
coordination-common.ps1
├─ 封闭清单与隔离断言
├─ 单端或选择性组合 start/stop/status
├─ 健康检查、版本和清单摘要
└─ PID + 启动器 + 命令令牌归属保护
```
`coordination.schema.json` 定义版本 `yovision.coordination/v1`;`coordination.example.json` 只提供不可投产占位。公共实现 `coordination-common.ps1` 解析外部 env 数据但不执行其内容,校验产品命令位于各自包内、秘密路径位于仓库和包外,并拒绝路径、端口、数据库身份、JWT、Cookie 或机器身份复用。三个薄入口脚本分别调用公共实现,BAT 只透传参数和退出码。
协调层不拥有业务数据或契约,不共享用户表、JWT、Cookie、数据库内部模型、摄像头凭据或产品实现。它不替代 `contracts/**`,也不让任一产品成为另一产品的启动前置;connector 可关闭,三端核心能力继续独立运行。根级运行状态位于清单指定的 `runtime_root\state`,产品日志仍归各自日志目录和产品入口管理。
<!-- coordination-deployment-v1:end -->
+26 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: 59f6eb5816b5d3f78c85ef902053c5fc45b8e6e9
synchronized_at: 2026-08-31T07:33:54Z
wiki_revision: 7e5ec44eb0afec0b84010ce6cf82177491272c70
synchronized_at: 2026-08-31T08:25:49Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -751,3 +751,27 @@ git diff --check
工单验收未使用客户 PKI、生产 PostgreSQL、真实三端网络或生产流量;这些结果只能由 #154 部署和 #155 E2E 补充。Sense 本地候选原子 Outbox 当前没有生产创建 caller,也不得据此声明本地产生链已完整接通。
<!-- integration-connectors-v1:end -->
<!-- coordination-deployment-v1:start -->
## 根级编排本地验证
工单 #154 的编排验证只使用仓库外临时目录和假交付包,不需要真实密码、数据库或客户 PKI。先运行清单和脚本验证:
```powershell
pwsh -NoProfile -File scripts/runtime/coordination/start-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -ValidateOnly
pwsh -NoLogo -NoProfile -File deploy/coordination/tests/coordination-smoke.ps1
```
冒烟测试必须返回 0,并覆盖 Sense、Brain、Bell 分别启动/状态/停止、Brain+Bell 选择性组合、停止单端不影响另一端、单端启动失败隔离、端口占用与重复端口拒绝、重复数据库身份拒绝、无归属 PID 保护、清单漂移诊断、退出码和状态清理。测试创建的目录必须解析在系统临时目录内,结束时只清理该测试目录。
仓库闭环验证:
```powershell
python dev_scripts/harness.py check --strict
python -m unittest discover -s tests -v
python dev_scripts/harness.py sync --check
git diff --check
```
这些测试证明编排和隔离控制,不证明真实产品包、生产 PostgreSQL、客户 PKI、真实摄像头/GPU、供应商服务、容量或长稳表现。真实契约闭环与 Brain/Bell 离线、重启、重复/冲突及证据降级由后续 E2E 工单验证。
<!-- coordination-deployment-v1:end -->
+28 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: fdd44bfe589d65cbce6ec085878abae2702d59e7
synchronized_at: 2026-08-31T07:23:47Z
wiki_revision: 0d4a70626b4ab8cc65df3d8a56975376684dd284
synchronized_at: 2026-08-31T08:26:11Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -195,3 +195,29 @@ synchronized_at: 2026-08-31T07:23:47Z
日志只记录稳定错误码、request/correlation ID、已认证 principal/kid 和脱敏业务引用;不得记录私钥、令牌、完整 Authorization、摄像头凭据、内部证据路径或事件完整敏感载荷。
<!-- integration-connectors-v1:end -->
<!-- coordination-deployment-v1:start -->
## 根级协调编排排错
工单 #154 已于 2026-08-31 验收;本节适用于已验收的可选根级编排。
先使用与启动时相同的仓库外清单查询状态:
```powershell
pwsh -NoProfile -File scripts/runtime/coordination/status-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product all
```
| 现象/状态 | 检查 | 安全处理 |
|---|---|---|
| 清单校验失败 | 检查包、env、私钥路径是否存在且位于仓库和包外;检查三端端口、目录、数据库、Cookie、JWT、账户和身份是否独立 | 修正仓库外清单或环境文件;不得放宽隔离断言或把秘密写进仓库 |
| `port ... is already in use` | 用状态命令确认是否已有受管实例,再检查对应监听端口 | 先按归属停止旧实例或为产品分配独立端口;不得终止未知进程 |
| `stopped` | 没有该产品状态文件 | 按需单独启动;不代表其他产品异常 |
| `unhealthy` | 进程仍归属本实例,但 HTTP/进程健康检查失败 | 查看该产品独立 `coordination.err.log`、`coordination.out.log` 和产品日志;修复该端,不自动重启其他端 |
| `stale` / `ownership-mismatch` | PID 已复用、启动器或命令令牌与状态不符 | 不会停止该进程;人工核对进程与状态文件,确认归属后再处理 |
| `stale` / `manifest-drift` | 运行中的实例来自不同清单摘要 | 使用原清单安全停止,或确认归属后停止再以新清单启动;不得用新清单覆盖运行事实 |
| 单端启动失败 | 查看该端协调日志、产品日志和退出码 | 编排只清理该端新进程;确认其他端状态,修复失败端后单独重试 |
| connector 使对端成为启动强依赖 | connector 开关或产品配置错误 | 关闭对应 event export、ingress、relay 或 evidence connector,恢复三端独立运行;保留 Outbox/Receipt/Event 等持久事实 |
| 停止命令拒绝执行 | 状态归属不匹配,或产品停止入口返回非零 | 不使用无条件 taskkill;先核对 PID、启动器、命令令牌和产品停止日志 |
日志和状态不得包含环境变量值、密码、JWT、token、私钥、完整 Authorization、摄像头凭据或客户数据。协调层故障时可停止使用根级入口并恢复三个产品各自的已验收启动脚本,不删除数据或共享事实。
<!-- coordination-deployment-v1:end -->
+28 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
wiki_revision: fa7e2031338cbca0f669f5d5a72a073670114882
synchronized_at: 2026-08-31T07:35:46Z
wiki_revision: f71d21296bc4d402046a52903e6e9ac0583dbd66
synchronized_at: 2026-08-31T08:27:50Z
<!-- gitea-wiki-mirror:end -->
# YoVision 部署与运维
@@ -188,3 +188,29 @@ Bell 运行变量:
回退时关闭 Brain `event_export.enabled`、Sense 两个 connector 开关和 Bell ingress/evidence 开关;保留 last-known-good、运行投影、InboundEvent、EvidenceRecord、Outbox、ReplayToken、Receipt、Event 与审计。不得删除事实、关闭 TLS/验签或改用网页登录态。
<!-- integration-connectors-v1:end -->
<!-- coordination-deployment-v1:start -->
## 三项目可选根级部署编排
工单 #154 已于 2026-08-31 验收。根级编排只组合 Sense、Brain、Bell 已审核交付包,不复制产品实现,也不改变三端独立交付边界。事实入口如下:
- 清单 Schema:`deploy/coordination/coordination.schema.json`
- 无秘密示例:`deploy/coordination/coordination.example.json`
- 操作说明:`deploy/coordination/README.md`
- 启动、停止、状态入口:`scripts/runtime/coordination/{start,stop,status}-yovision.ps1`,并提供同名 BAT 包装器
生产或验收环境必须先把示例清单复制到仓库外受控目录,并分别准备仓库外 Sense、Brain、Bell 环境文件和每实例独立私钥。Sense/Bell 使用不同数据库、数据库角色、账户空间、浏览器 origin、Cookie、JWT、端口、包、数据目录和日志目录;三端机器 principal、key id 和私钥文件也不得复用。清单只记录版本、路径和公开标识,不保存密码、JWT、token 或私钥内容。
从仓库根目录使用:
```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
```
启动顺序为 Bell → Sense → Brain,停止顺序反向。启动时 `all` 只包含 `enabled=true` 的产品;停止和状态查询时 `all` 覆盖三端,避免停用配置后遗留进程。编排状态只保存 PID、版本、清单摘要和命令归属元数据;停止前必须核对 PID、启动器与命令令牌,归属不匹配时拒绝终止。单端启动失败只清理该端新进程,不自动停止其他端。
升级时每次只替换一个独立包并更新精确版本,先备份 Sense/Bell,再按 Bell → Sense → Brain 验证,最后启用 connector。回退时先停用 Brain event export、Sense ingress/relay 与 Bell ingress/evidence connector,再使用各产品独立入口回退包或恢复数据库;不得删除 Outbox、Receipt、Event、运行投影、replay 或审计事实。16 路只是当前交付配额,不是编排器硬上限;真实生产包、PostgreSQL、客户 PKI、真机容量与长稳仍需部署环境验收。
<!-- coordination-deployment-v1:end -->
@@ -0,0 +1,4 @@
@echo off
setlocal
pwsh.exe -NoProfile -File "%~dp0run-coordination-e2e.ps1" %*
exit /b %ERRORLEVEL%
@@ -0,0 +1,191 @@
[CmdletBinding()]
param(
[string]$PostgresBin = 'D:\pgsql17\bin',
[string]$Python = '',
[switch]$SkipIndependentProductE2E,
[switch]$KeepTemporary
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
$temporaryRoot = [IO.Path]::GetFullPath((Join-Path ([IO.Path]::GetTempPath()) ('yovision-coordination-e2e-' + [guid]::NewGuid().ToString('N'))))
$postgresData = Join-Path $temporaryRoot 'postgres'
$postgresLog = Join-Path $temporaryRoot 'postgres.log'
$postgresStarted = $false
$savedEnvironment = @{}
$sensitiveValues = [Collections.Generic.List[string]]::new()
function Get-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
}
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
$client = [Net.Sockets.TcpClient]::new()
try { $connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected } catch { $connected = $false } finally { $client.Dispose() }
if ($connected -eq $Open) { return }
Start-Sleep -Milliseconds 250
}
throw "TCP port $Port did not reach open=$Open"
}
function New-RandomName([string]$Prefix) {
return $Prefix + '_' + [guid]::NewGuid().ToString('N').Substring(0, 12)
}
function New-RandomSecret {
$buffer = New-Object byte[] 48
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
return [Convert]::ToBase64String($buffer).Replace('+', 'A').Replace('/', 'B')
}
function Set-TestEnvironment([string]$Name, [string]$Value, [bool]$Sensitive = $false) {
if (-not $script:savedEnvironment.ContainsKey($Name)) {
$script:savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process')
}
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
if ($Sensitive) { $script:sensitiveValues.Add($Value) }
}
function Invoke-Checked {
param([string]$Name, [string]$WorkingDirectory, [scriptblock]$Command)
Write-Host "[coordination-e2e] $Name"
Push-Location $WorkingDirectory
try {
& $Command
if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" }
} finally { Pop-Location }
}
function Assert-NoSecretInLogs {
$logs = @(Get-ChildItem -LiteralPath $temporaryRoot -File -Recurse -ErrorAction SilentlyContinue)
foreach ($log in $logs) {
$stream = [IO.File]::Open($log.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)
try {
$reader = [IO.StreamReader]::new($stream, [Text.Encoding]::UTF8, $true)
try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() }
} finally { $stream.Dispose() }
foreach ($secret in $sensitiveValues) {
if ($secret.Length -ge 8 -and $content.Contains($secret)) {
throw "Temporary log exposed a generated E2E secret: $($log.Name)"
}
}
}
}
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
try {
foreach ($tool in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
$path = Join-Path $PostgresBin $tool
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required PostgreSQL tool not found: $path" }
}
if ([string]::IsNullOrWhiteSpace($Python)) {
$candidate = Join-Path $repositoryRoot 'Brain\.venv\Scripts\python.exe'
$Python = if (Test-Path -LiteralPath $candidate -PathType Leaf) { $candidate } else { 'python.exe' }
}
$contractEnvironment = Join-Path $temporaryRoot 'contract-venv'
& $Python -m venv $contractEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Could not create the isolated contract-test environment.' }
$contractPython = Join-Path $contractEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
& $contractPython -m pip install --quiet -r (Join-Path $repositoryRoot 'contracts\tests\source-config-v1\requirements.txt') 'cryptography==50.0.1'
if ($LASTEXITCODE -ne 0) { throw 'Could not install the pinned contract-test dependencies.' }
$postgresPort = Get-FreeTcpPort
if ($postgresPort -eq 5432) { throw 'Coordination E2E refuses the default PostgreSQL port.' }
$clusterUser = New-RandomName 'yvcoord'
$senseRole = New-RandomName 'sense_owner'
$bellRole = New-RandomName 'bell_owner'
$senseDatabase = New-RandomName 'sense_e2e'
$bellDatabase = New-RandomName 'bell_e2e'
& (Join-Path $PostgresBin 'initdb.exe') -D $postgresData -U $clusterUser -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Isolated PostgreSQL initdb failed.' }
$startArguments = "-D `"$postgresData`" -l `"$postgresLog`" -o `"-p $postgresPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $startArguments -RedirectStandardOutput (Join-Path $temporaryRoot 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporaryRoot 'pg-ctl.err.log') -WindowStyle Hidden | Out-Null
Wait-Tcp -Port $postgresPort -Open $true
$postgresStarted = $true
$psql = Join-Path $PostgresBin 'psql.exe'
foreach ($role in @($senseRole, $bellRole)) {
& $psql -X -h 127.0.0.1 -p $postgresPort -U $clusterUser -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE $role LOGIN;" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not create isolated role $role" }
}
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $postgresPort -U $clusterUser -O $senseRole $senseDatabase
if ($LASTEXITCODE -ne 0) { throw 'Could not create isolated Sense database.' }
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $postgresPort -U $clusterUser -O $bellRole $bellDatabase
if ($LASTEXITCODE -ne 0) { throw 'Could not create isolated Bell database.' }
$senseDsn = "host=127.0.0.1 port=$postgresPort user=$senseRole dbname=$senseDatabase sslmode=disable"
$bellDsn = "host=127.0.0.1 port=$postgresPort user=$bellRole dbname=$bellDatabase sslmode=disable"
if ($senseDsn -eq $bellDsn -or $senseRole -eq $bellRole -or $senseDatabase -eq $bellDatabase) { throw 'Sense and Bell isolation invariant failed.' }
Set-TestEnvironment 'GOTOOLCHAIN' 'go1.26.5'
Set-TestEnvironment 'PYTHONDONTWRITEBYTECODE' '1'
Set-TestEnvironment 'SENSE_OUTBOX_TEST_DATABASE_URL' $senseDsn
Set-TestEnvironment 'BELL_DATABASE_URL' $bellDsn
Set-TestEnvironment 'BELL_EVENT_INGRESS_TEST_DATABASE_URL' $bellDsn
Set-TestEnvironment 'BELL_RULE_ALERT_TEST_DATABASE_URL' $bellDsn
Set-TestEnvironment 'BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL' $bellDsn
Set-TestEnvironment 'BELL_JWT_SECRET' (New-RandomSecret) $true
Set-TestEnvironment 'BELL_BOOTSTRAP_USERNAME' (New-RandomName 'coord_admin')
Set-TestEnvironment 'BELL_BOOTSTRAP_PASSWORD' (New-RandomSecret) $true
Set-TestEnvironment 'BELL_RULE_ALERT_OPERATOR_PASSWORD' (New-RandomSecret) $true
Set-TestEnvironment 'BELL_HOST' '127.0.0.1'
Set-TestEnvironment 'BELL_PORT' (Get-FreeTcpPort).ToString()
Invoke-Checked 'Bell formal migrations' (Join-Path $repositoryRoot 'Bell\server') { go run . migrate -c config/settings.demo.yml *> (Join-Path $temporaryRoot 'bell-migrate.log') }
Invoke-Checked 'source-config v1 contract' $repositoryRoot { & $contractPython -m unittest discover -s contracts/tests/source-config-v1 -p 'test_*.py' -v }
Invoke-Checked 'runtime-status v1 contract' $repositoryRoot { & $contractPython contracts/tests/runtime-status-v1/test_contract.py }
Invoke-Checked 'machine-identity v1 cross-language contract' $repositoryRoot { & $contractPython contracts/tests/machine-identity-v1/test_contract.py }
Invoke-Checked 'events v1 contract' $repositoryRoot { & $contractPython contracts/tests/events-v1/test_contract.py }
Invoke-Checked 'evidence v1 contract' $repositoryRoot { & $contractPython contracts/tests/evidence-v1/test_contract.py }
Invoke-Checked 'Sense source/status integration' (Join-Path $repositoryRoot 'Sense\tests\integration\brain_control') { go test . -count=1 -v }
Invoke-Checked 'Sense Brain-event/evidence/Outbox integration' (Join-Path $repositoryRoot 'Sense\tests\integration\bell_connector') { go test . -count=1 -v }
Invoke-Checked 'Sense PostgreSQL Outbox recovery' (Join-Path $repositoryRoot 'Sense\server') { go test ./app/sense/outbox -count=1 -v }
Invoke-Checked 'Brain source/status connector and anonymous event export' $repositoryRoot {
& $Python -m pytest Brain/tests/integration/sense_control Brain/tests/integration/event_export -q
}
Invoke-Checked 'Bell ingress and evidence degradation' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/integration/event_ingress -count=1 -v }
Invoke-Checked 'Bell rule and alert projection' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/bell_rule_alert -count=1 -v }
Invoke-Checked 'Bell alert lifecycle' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/bell_alert_lifecycle -count=1 -v }
$identityFacts = (& $psql -X -h 127.0.0.1 -p $postgresPort -U $clusterUser -d postgres -tAc "select datname||':'||pg_get_userbyid(datdba) from pg_database where datname in ('$senseDatabase','$bellDatabase') order by datname;")
if (@($identityFacts).Count -ne 2 -or ($identityFacts -join '|') -notmatch [regex]::Escape($senseRole) -or ($identityFacts -join '|') -notmatch [regex]::Escape($bellRole)) {
throw 'PostgreSQL ownership isolation evidence is incomplete.'
}
if (-not $SkipIndependentProductE2E) {
Invoke-Checked 'Sense independent isolated E2E regression' $repositoryRoot { & (Join-Path $repositoryRoot 'Sense\tests\e2e\run-isolated-e2e.ps1') -PostgresBin $PostgresBin }
Invoke-Checked 'Brain independent test regression' $repositoryRoot { & $Python -m pytest Brain/tests -q }
Invoke-Checked 'Bell independent isolated E2E regression' $repositoryRoot { & (Join-Path $repositoryRoot 'Bell\tests\e2e\run-isolated-e2e.ps1') -PostgresBin $PostgresBin }
}
Assert-NoSecretInLogs
Write-Host "COORDINATION_E2E passed: versioned contracts, source/status, anonymous event, durable Outbox recovery, Bell Receipt/Event/Alert lifecycle, identity/replay/conflict/evidence faults, separate Sense/Bell databases. postgres_port=$postgresPort"
} finally {
if ($postgresStarted) {
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$postgresData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporaryRoot 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporaryRoot 'pg-stop.err.log') -WindowStyle Hidden -Wait | Out-Null
try { Wait-Tcp -Port $postgresPort -Open $false -Attempts 40 } catch {}
}
foreach ($entry in $savedEnvironment.GetEnumerator()) {
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
}
if ($KeepTemporary) {
Write-Host "Kept coordination E2E directory: $temporaryRoot"
} elseif (Test-Path -LiteralPath $temporaryRoot) {
$resolved = [IO.Path]::GetFullPath($temporaryRoot)
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if (-not $resolved.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not ([IO.Path]::GetFileName($resolved)).StartsWith('yovision-coordination-e2e-')) {
throw "Refusing unsafe temporary cleanup: $resolved"
}
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}
@@ -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' }) }
}
@@ -0,0 +1,3 @@
@echo off
pwsh.exe -NoProfile -File "%~dp0start-yovision.ps1" %*
exit /b %ERRORLEVEL%
@@ -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
}
@@ -0,0 +1,3 @@
@echo off
pwsh.exe -NoProfile -File "%~dp0status-yovision.ps1" %*
exit /b %ERRORLEVEL%
@@ -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
}
@@ -0,0 +1,3 @@
@echo off
pwsh.exe -NoProfile -File "%~dp0stop-yovision.ps1" %*
exit /b %ERRORLEVEL%
@@ -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
}
+18
View File
@@ -0,0 +1,18 @@
# Coordination E2E
Run the complete isolated coordination acceptance from the repository root:
```powershell
pwsh scripts/e2e/coordination/run-coordination-e2e.ps1
```
The runner creates a temporary PostgreSQL cluster on a dynamic loopback port,
uses distinct random owners and databases for Sense and Bell, exercises the
frozen contracts and the three connector chains, runs each product's existing
independent regression, checks generated secrets are absent from temporary
logs, and removes only the processes and directory it created.
`-SkipIndependentProductE2E` is intended only for local harness debugging and
does not satisfy issue #155 acceptance. `-KeepTemporary` preserves disposable
diagnostics after a failed run; the directory contains test-only generated
credentials and must not be committed or shared.