[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 '部分原容量未恢复,请按保存的迁移计划处理。' } }