Files
xtrhrj/scripts/Observe-OriginalImage.ps1
T

208 lines
14 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[CmdletBinding()]
param(
[string]$CoordinatorUrl = 'http://127.0.0.1:18100',
[string]$CredentialFile = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/cluster.local.properties'),
[ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$SourceEndpointId,
[ValidateRange(1,3600)][int]$DurationSeconds = 30,
[ValidateRange(100,5000)][int]$PollMilliseconds = 500,
[string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/image-observation'),
[switch]$InventoryOnly,
[switch]$AllowPending,
[switch]$QuietFrames,
[switch]$PrepareMigration,
[switch]$Migrate,
[string]$FromNodeId,
[string]$ToNodeId
)
. (Join-Path $PSScriptRoot 'Image-Verification.Common.ps1')
function Get-ImageTask {
param($Snapshot)
$matches = @($Snapshot.tasks | Where-Object { $_.mediaKind -eq 'ORIGINAL_IMAGE' -and $_.sourceEndpointId -eq $SourceEndpointId })
if ($matches.Count -gt 1) { throw '来源出现多个原图任务,已停止联调。' }
if ($matches.Count -eq 0) { return $null }
return $matches[0]
}
function Write-ImageRecord {
param($Record)
# 白名单记录不含 playUrl/mediaKey、私有设备密钥、集群凭证或 PXHJ 会话。
[IO.File]::AppendAllText($script:journalFile, ($Record | ConvertTo-Json -Depth 5 -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
}
function Write-ImageTaskState {
param($Snapshot)
$task = Get-ImageTask -Snapshot $Snapshot
$status = if ($null -eq $task) { 'NOT_REGISTERED' } else { [string]$task.status }
$nodeId = if ($null -ne $task -and $task.PSObject.Properties['nodeId']) { [string]$task.nodeId } else { '' }
$version = if ($null -ne $task -and $task.PSObject.Properties['version']) { [long]$task.version } else { 0L }
$ready = $null -ne $task -and $task.PSObject.Properties['imageReady'] -and [bool]$task.imageReady
$stateKey = $status + '/' + $nodeId + '/' + $version + '/' + $ready
if ($stateKey -eq $script:lastTaskState) { return }
$record = [ordered]@{ event = 'task-state'; observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
endpointId = $SourceEndpointId; status = $status; nodeId = $nodeId; version = $version; imageReady = $ready }
[IO.File]::AppendAllText((Join-Path $script:observationRoot 'task-states.jsonl'),
($record | ConvertTo-Json -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
Write-Information ('原图任务状态:' + $status + ',node=' + $nodeId + ',version=' + $version + ',imageReady=' + $ready) -InformationAction Continue
$script:lastTaskState = $stateKey
}
function Read-CurrentOriginalFrame {
param($Snapshot)
$task = Get-ImageTask -Snapshot $Snapshot
if ($null -eq $task -or -not $task.PSObject.Properties['nodeId'] -or -not $task.PSObject.Properties['version']) { return $null }
$node = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $task.nodeId })
if ($node.Count -ne 1 -or -not $node[0].online) { return $null }
$path = '/node/images/' + [Uri]::EscapeDataString([string]$task.taskId) + '/' + [long]$task.version
$metadataResponse = Invoke-XtrhImageSigned -BaseUrl $node[0].baseUrl -Path ($path + '/latest')
if ($metadataResponse.Status -eq 404) { return $null }
$metadata = ConvertFrom-XtrhImageJson -Response $metadataResponse
if ($metadata.endpointId -ne $SourceEndpointId -or [int]$metadata.bytes -lt 4 -or [int]$metadata.bytes -gt 2097152 -or
[int]$metadata.width -lt 1 -or [int]$metadata.width -gt 4096 -or [int]$metadata.height -lt 1 -or [int]$metadata.height -gt 4096) {
throw '原图元数据与请求来源或大小限制不一致。'
}
$frameResponse = Invoke-XtrhImageSigned -BaseUrl $node[0].baseUrl -Path ($path + '/frames/' + [Uri]::EscapeDataString([string]$metadata.frameId))
# 迁移或近期帧淘汰可使取图返回 404,重新查询即可,不能借用其他版本的图片。
if ($frameResponse.Status -eq 404) { return $null }
if ($frameResponse.Status -ne 200 -or $frameResponse.ContentType -notmatch '^image/jpeg(?:;|$)' -or
$frameResponse.Bytes.Length -ne [int]$metadata.bytes -or $frameResponse.Bytes[0] -ne 255 -or $frameResponse.Bytes[1] -ne 216 -or
$frameResponse.Bytes[-2] -ne 255 -or $frameResponse.Bytes[-1] -ne 217) { throw '节点未返回一致的签名 JPEG。' }
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$record = [ordered]@{
observedTime = $now; endpointId = $SourceEndpointId; taskId = $task.taskId; version = [long]$task.version
nodeId = $task.nodeId; frameId = $metadata.frameId; sourceTime = [long]$metadata.sourceTime
receivedTime = [long]$metadata.receivedTime; ageMs = $now - [long]$metadata.sourceTime
width = [int]$metadata.width; height = [int]$metadata.height; bytes = $frameResponse.Bytes.Length
sha256 = Get-XtrhImageDigest -Bytes $frameResponse.Bytes; responseAuthenticated = $true
}
# 图像按帧号缓存,只落业务图片与白名单元数据,绝不保存原始总控完整快照。
if ($record.frameId -ne $script:lastFrameId -or $record.nodeId -ne $script:lastNodeId -or $record.version -ne $script:lastVersion) {
[IO.File]::WriteAllBytes((Join-Path $script:observationRoot ('frame-' + [Guid]::Parse($record.frameId).ToString() + '.jpg')), $frameResponse.Bytes)
Write-ImageRecord -Record $record
if (-not $QuietFrames) {
Write-Information ('原图已验证:node=' + $record.nodeId + ',version=' + $record.version + ',sourceTime=' + $record.sourceTime +
',receivedTime=' + $record.receivedTime + ',ageMs=' + $record.ageMs + ',bytes=' + $record.bytes) -InformationAction Continue
}
$script:lastFrameId = $record.frameId; $script:lastNodeId = $record.nodeId; $script:lastVersion = $record.version
$script:verifiedFrames++
if ($record.ageMs -ge -5000 -and $record.ageMs -le 5000) { $script:freshFrames++ }
}
return [pscustomobject]$record
}
function Get-MigrationPlan {
param($Snapshot)
if (-not $SourceEndpointId -or -not $FromNodeId -or -not $ToNodeId -or $FromNodeId -eq $ToNodeId) {
throw '迁移准备需提供来源 endpoint、不同的起点和终点 nodeId。'
}
$source = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $FromNodeId })
$target = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $ToNodeId })
if ($source.Count -ne 1 -or $target.Count -ne 1 -or -not $source[0].online -or -not $target[0].online -or
-not $target[0].enabled -or [int]$target[0].imageInputPort -lt 1) { throw '迁移节点未在线或目标原图接入未就绪。' }
# 强制指定目标需要临时限制其他节点,因此只允许当前来源这一项活任务存在。
# 用户正在运行的其他试验或其他原图通道存在时拒绝操作,避免改变无关调度。
$foreign = @($Snapshot.tasks | Where-Object { $_.status -ne 'ENDED' -and
(-not $_.PSObject.Properties['sourceEndpointId'] -or $_.sourceEndpointId -ne $SourceEndpointId) })
if ($foreign.Count -gt 0) { throw '存在其他活任务,不能通过全节点容量执行定向迁移。' }
$changes = @($Snapshot.nodes | Where-Object online | ForEach-Object {
[pscustomobject]@{ nodeId = $_.nodeId; savedCapacity = [int]$_.capacity
temporaryCapacity = $(if ($_.nodeId -eq $ToNodeId) { [Math]::Max(1, [int]$_.capacity) } else { 0 }) }
})
return [pscustomobject]@{ coordinatorId = $Snapshot.serviceId; endpointId = $SourceEndpointId
fromNodeId = $FromNodeId; toNodeId = $ToNodeId; capacities = $changes }
}
function Set-TemporaryCapacity {
param([string]$NodeId, [int]$Capacity)
$response = Invoke-XtrhImageSigned -BaseUrl $CoordinatorUrl -Path ('/control/nodes/' + [Uri]::EscapeDataString($NodeId) + '/capacity') `
-Method PATCH -Payload @{ capacity = $Capacity }
if ($response.Status -ne 200) { throw ('节点容量变更失败,HTTP ' + $response.Status) }
}
$script:observationRoot = [IO.Path]::GetFullPath($OutputDirectory)
[void][IO.Directory]::CreateDirectory($script:observationRoot)
$script:journalFile = Join-Path $script:observationRoot 'frames.jsonl'
$script:lastFrameId = ''; $script:lastNodeId = ''; $script:lastVersion = 0L; $script:verifiedFrames = 0; $script:freshFrames = 0
$script:lastTaskState = ''
$changed = [Collections.Generic.List[object]]::new()
$plan = $null
Initialize-XtrhImageVerification -CredentialFile $CredentialFile
try {
$initial = Get-XtrhImageSnapshot -CoordinatorUrl $CoordinatorUrl
$inventory = [ordered]@{ observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); epoch = $initial.epoch
nodes = @($initial.nodes | Select-Object nodeId,name,nodeRole,online,baseUrl,imageHost,imageInputPort,capacity,running,reserved)
routes = @($initial.routes | Select-Object endpointId,sourceHost,sourcePort,relayPort,targetHost,targetPort)
tasks = @($initial.tasks | Select-Object taskId,mediaKind,sourceEndpointId,collectorId,desired,status,nodeId,version,imageReady,lastFrameTime) }
[IO.File]::WriteAllText((Join-Path $script:observationRoot 'inventory.json'), ($inventory | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
if ($InventoryOnly) {
Write-Output ('只读库存已保存:在线节点 ' + @($initial.nodes | Where-Object online).Count + ',任务 ' + @($initial.tasks).Count + ',UDP 路由 ' + @($initial.routes).Count)
return
}
if (-not $SourceEndpointId) { throw '持续观测需提供正式采集连接的 source endpointId。' }
if ($PrepareMigration -or $Migrate) {
$plan = Get-MigrationPlan -Snapshot $initial
[IO.File]::WriteAllText((Join-Path $script:observationRoot 'migration-plan.json'), ($plan | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false))
if ($PrepareMigration -and -not $Migrate) { Write-Output '迁移计划与原容量已保存,未执行任何变更。'; return }
}
if ($Migrate) {
$active = Get-ImageTask -Snapshot $initial
if ($null -eq $active -or -not $active.PSObject.Properties['nodeId'] -or $active.nodeId -ne $FromNodeId) {
throw '来源任务当前不在指定起点,拒绝执行迁移。'
}
# 先给目标留足接管空间,再限制其他节点;旧接收端等目标收到新鲜图后才撤销。
foreach ($change in @($plan.capacities | Sort-Object { if ($_.nodeId -eq $ToNodeId) { 0 } else { 1 } })) {
if ($change.savedCapacity -eq $change.temporaryCapacity) { continue }
$changed.Add($change)
Set-TemporaryCapacity -NodeId $change.nodeId -Capacity $change.temporaryCapacity
}
}
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($DurationSeconds)
$migrationObserved = $false
$oldVersionRevoked = $false
while ([DateTimeOffset]::UtcNow -lt $deadline) {
$snapshot = Get-XtrhImageSnapshot -CoordinatorUrl $CoordinatorUrl
Write-ImageTaskState -Snapshot $snapshot
$frame = Read-CurrentOriginalFrame -Snapshot $snapshot
if ($Migrate -and $null -ne $frame -and $frame.nodeId -eq $ToNodeId -and $frame.ageMs -ge -5000 -and $frame.ageMs -le 5000) {
$migrationObserved = $true
}
if ($Migrate -and $migrationObserved -and -not $oldVersionRevoked) {
# 新节点接管后旧节点需及时释放许可;首个心跳之前可能仍可读,窗口内持续检查。
$oldNode = @($initial.nodes | Where-Object { $_.nodeId -eq $FromNodeId })[0]
$oldPath = '/node/images/' + [Uri]::EscapeDataString([string]$active.taskId) + '/' + [long]$active.version + '/latest'
$oldResponse = Invoke-XtrhImageSigned -BaseUrl $oldNode.baseUrl -Path $oldPath
if ($oldResponse.Status -eq 404) {
$oldVersionRevoked = $true
$revokedRecord = [ordered]@{ event = 'old-version-revoked'; observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
endpointId = $SourceEndpointId; nodeId = $FromNodeId; version = [long]$active.version; signedHttpStatus = 404 }
[IO.File]::AppendAllText((Join-Path $script:observationRoot 'migration-events.jsonl'),
($revokedRecord | ConvertTo-Json -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
Write-Information '旧节点分配已撤销:私有原图查询返回签名 404。' -InformationAction Continue
} elseif ($oldResponse.Status -ne 200) { throw '旧节点未明确返回有效图片或已撤销状态。' }
}
Start-Sleep -Milliseconds $PollMilliseconds
}
if ($script:verifiedFrames -eq 0) {
# 部署前允许记录尚未登记,不将空窗口宣称为取图验收通过;迁移必须取得新图。
if ($AllowPending -and -not $Migrate -and $script:lastTaskState.StartsWith('NOT_REGISTERED/')) {
Write-Output '观测窗口结束:来源尚未登记,未得到原图,原图接入尚未验证。'
return
}
throw '观测期间没有取得经过签名验证的原图。'
}
if ($script:freshFrames -eq 0) { throw '只读到了旧缓存,观测期间没有收到五秒内的新鲜原图。' }
if ($Migrate -and -not $migrationObserved) { throw '观测期间未证明目标节点已接管新鲜原图。' }
if ($Migrate -and -not $oldVersionRevoked) { throw '目标已接管,但未证明旧节点版本已撤销。' }
Write-Output ('原图观测完成:验证 ' + $script:verifiedFrames + ' 个新帧,记录已保存。')
} finally {
# 仅 -Migrate 会进入恢复分支;默认观测与 -PrepareMigration 都没有容量写操作。
# 目标/原节点先恢复,再恢复其余节点,使用保存的正式原值,不推断历史默认容量。
$restoreFailed = $false
foreach ($change in @($changed | Sort-Object { if ($_.nodeId -eq $FromNodeId) { 0 } else { 1 } })) {
try { Set-TemporaryCapacity -NodeId $change.nodeId -Capacity $change.savedCapacity }
catch { $restoreFailed = $true; Write-Warning ('容量恢复失败:node=' + $change.nodeId + ',原值=' + $change.savedCapacity + ';请使用 migration-plan.json 恢复。') }
}
Close-XtrhImageVerification
if ($restoreFailed) { throw '部分原容量未恢复,请按保存的迁移计划处理。' }
}