初始化系统融合软件代码仓库

This commit is contained in:
2026-10-20 08:34:12 +08:00
commit 587ae6f088
96 changed files with 10230 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
[CmdletBinding()]
param([switch]$RestartRunningServices)
$ErrorActionPreference='Stop'
if(-not $RestartRunningServices){throw '此步骤会短暂停止PXHJ采集并刷新融合总控。需明确确认后使用 -RestartRunningServices。'}
$projectRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$pxhjRoot='E:\pxhj'
$coordinatorRoot=Join-Path $projectRoot 'runtime/coordinator'
$sourcePxhj=Join-Path $projectRoot 'runtime/console-pxhj-build/pxhj-server-0.1.0.jar'
$targetPxhj=Join-Path $pxhjRoot 'backend/target/pxhj-server-0.1.0.jar'
$sourceXtrh=Join-Path $projectRoot 'target/xtrh-services-1.0.0.jar'
foreach($file in @($sourcePxhj,$sourceXtrh)){if(-not(Test-Path -LiteralPath $file)){throw '缺少已验证构建产物。'}}
$listeners=@(Get-NetTCPConnection -State Listen -LocalPort 8088 -ErrorAction SilentlyContinue)
$backendPid=0
$backendTicks=0
if($listeners.Count){
$backendPid=[int]$listeners[0].OwningProcess
$process=Get-Process -Id $backendPid
$backendTicks=$process.StartTime.ToUniversalTime().Ticks
$command=Get-CimInstance Win32_Process -Filter ('ProcessId='+$backendPid)
$jar=[regex]::Match([string]$command.CommandLine,'(?i)-jar\s+(?:"(?<p>[^"]+)"|(?<p>\S+))').Groups['p'].Value
if([IO.Path]::GetFullPath($jar) -ne [IO.Path]::GetFullPath($targetPxhj)){throw '8088不是目标PXHJ进程,未停止任何程序。'}
}
$backup=Join-Path $projectRoot ('runtime/console-activation-backup-'+(Get-Date -Format 'yyyyMMdd-HHmmss'))
New-Item -ItemType Directory -Force -Path $backup | Out-Null
Copy-Item -LiteralPath $targetPxhj -Destination (Join-Path $backup 'pxhj-server.jar')
Copy-Item -LiteralPath (Join-Path $coordinatorRoot 'xtrh-services.jar') -Destination (Join-Path $backup 'xtrh-services.jar')
if($backendPid){
$current=Get-Process -Id $backendPid -ErrorAction Stop
if($current.StartTime.ToUniversalTime().Ticks -ne $backendTicks){throw 'PXHJ进程身份已变化,未停止。'}
Stop-Process -Id $backendPid
Wait-Process -Id $backendPid -Timeout 15 -ErrorAction SilentlyContinue
}
& (Join-Path $projectRoot 'scripts/Stop-Xtrh.ps1') -InstallRoot $coordinatorRoot
Copy-Item -LiteralPath $sourcePxhj -Destination $targetPxhj
Copy-Item -LiteralPath $sourceXtrh -Destination (Join-Path $coordinatorRoot 'xtrh-services.jar')
foreach($pair in @(@{source=$sourcePxhj;target=$targetPxhj},@{source=$sourceXtrh;target=(Join-Path $coordinatorRoot 'xtrh-services.jar')})){
if((Get-FileHash -LiteralPath $pair.source).Hash -ne (Get-FileHash -LiteralPath $pair.target).Hash){throw '加载前JAR校验失败。'}
}
$env:JAVA_HOME='C:\Program Files\Java\jdk-25.0.2'
$env:PATH=$env:JAVA_HOME+'\bin;'+$env:PATH
& (Join-Path $projectRoot 'scripts/Start-Xtrh.ps1') -InstallRoot $coordinatorRoot -JavaHome $env:JAVA_HOME
& (Join-Path $pxhjRoot 'scripts/start-dev.ps1') -SkipBuild -BackendOnly
Write-Output ('新页面和真实采集桥接已加载;原包备份:'+$backup)
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""核验并切换已构建图片协商候选;默认预览,--apply执行既已授权的板端切换。"""
import argparse
import contextlib
import importlib.util
import io
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
import time
import uuid
# 操作者负责SSH接入,此工具没有网络配置、口令或自动连接行为。
loader = importlib.util.spec_from_file_location("collector_stage", Path(__file__).with_name("Stage-EmbeddedImageRoute.py"))
helper = importlib.util.module_from_spec(loader)
loader.loader.exec_module(helper)
def test_result(path, minimum, optional_interop=False):
# 不根据built布尔值省略原始QtTest结果;失败或其他跳过不能进入现网。
text = path.read_text(encoding="utf-8")
result = re.search(r"Totals:\s+(\d+) passed,\s+(\d+) failed,\s+(\d+) skipped", text)
if not result:
raise RuntimeError("QtTest result summary is missing: " + path.name)
passed, failed, skipped = map(int, result.groups())
if passed < minimum or failed or skipped > (1 if optional_interop else 0):
raise RuntimeError("QtTest results did not meet deployment requirements: " + path.name)
# ARM没有Windows生成的Java夹具时仅允许这一项跳过,原图/租约等业务不能跳过。
if skipped and "javaAndCppWireCodecInteroperateWithRealEncryptedFrames" not in text:
raise RuntimeError("Unexpected skipped test: " + path.name)
return {"passed": passed, "failed": failed, "skipped": skipped}
def preflight(args):
root, directory, record = helper.stage_record(args)
if not record.get("built"):
raise RuntimeError("Full ARM candidate build has not passed")
# 先确认在用版本从stage后没有被别的部署修改,完整核验在停止进程前完成。
with contextlib.redirect_stdout(io.StringIO()):
helper.verify(args)
# 源码/pro也必须与完整构建时的记录一致,避免把未构建的改动一起装入现网。
checks = dict(record["candidatePatchHashes"])
checks["px_Collect.pro"] = record["candidateProjectSha256"]
checks["bin/px_JKGL"] = record["candidateBinarySha256"]
for relative, checksum in checks.items():
candidate = directory / "source" / relative
# 只能从本stage取候选,路径和内容均受校验,不能替换为任意二进制文件。
if candidate.is_symlink() or not candidate.resolve().is_relative_to(directory) \
or not candidate.is_file() or helper.digest(candidate) != checksum:
raise RuntimeError("Candidate hash or path mismatch: " + relative)
config = directory / "candidate-config.ini"
if config.is_symlink() or helper.digest(config) != record["candidateConfigSha256"]:
raise RuntimeError("Candidate configuration hash mismatch")
# 这里只采信stage内本次两套结果,不借用历史Windows或旧板的测试报告。
tests = {"logging": test_result(directory / "logging-tests.txt", 9),
"imageRoute": test_result(directory / "image_route-tests.txt", 8, True)}
launcher = root / "start-network-simulation.sh"
# 保留当前启动器:它维护串口PTY和collector.pid,不能用历史拷贝替换。
if launcher.is_symlink() or not launcher.is_file() or not os.access(str(launcher), os.X_OK):
raise RuntimeError("Original network simulation launcher is invalid")
if re.search(r">>\s*logs/run\.log", launcher.read_text(encoding="utf-8")):
# 已有内置日志不能回退到旧的无限run.log重定向策略。
raise RuntimeError("Original launcher still contains legacy unlimited run.log redirection")
# 配置不是源码白名单的一项,但目的路径同样必须留在当前collector根目录内。
for relative in checks.keys() | {"config.ini"}:
destination = root / relative
if destination.is_symlink() or not destination.parent.resolve().is_relative_to(root):
raise RuntimeError("Unsafe live destination: " + relative)
return root, directory, record, checks, tests, helper.digest(launcher)
def stop_collector(root):
# 只停止真实exe属于该工程的采集程序;串口桥、平台与其他同名进程保留。
original = helper.process_ids(root)
for pid in original:
os.kill(pid, signal.SIGTERM)
# 使用单调期限,让系统校时不会延长停止等待或误触发提前强制退出。
deadline = time.monotonic() + 5
while set(original) & set(helper.process_ids(root)) and time.monotonic() < deadline:
time.sleep(0.1)
for pid in set(original) & set(helper.process_ids(root)):
os.kill(pid, signal.SIGKILL)
time.sleep(0.1)
if helper.process_ids(root):
raise RuntimeError("Collector has another restart owner; no files were replaced")
def install(source, destination):
# 同目录临时文件+原子替换,避免正在部署时出现截断二进制或半写INI。
# copy2保留已校验候选的权限;生产父目录与路径已在preflight核对。
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(destination.name + ".image-route-" + uuid.uuid4().hex)
try:
shutil.copy2(source, temporary)
os.replace(str(temporary), str(destination))
finally:
if temporary.exists():
# 只清理本次创建的单个临时文件,不递归处理任何目录。
temporary.unlink()
def launch(root, directory, launcher_hash):
launcher = root / "start-network-simulation.sh"
if helper.digest(launcher) != launcher_hash:
raise RuntimeError("Original launcher changed during activation")
# 启动器原样运行,自行管理串口桥与collector.pid;不修改原配置或网络。
with (directory / "activation-launcher.log").open("wb") as output:
subprocess.run([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL, stdout=output,
stderr=subprocess.STDOUT, check=True, timeout=15)
def healthy(root, since, previous_status, timeout):
status_path = root / "runtime/native-log-status.json"
deadline = time.monotonic() + timeout
# 每次健康失效重置稳定窗口,短暂启动后立即崩溃不会算成功。
stable_since = None
while time.monotonic() < deadline:
pids = helper.process_ids(root)
try:
stamp = status_path.stat()
status = json.loads(status_path.read_text(encoding="utf-8"))
# 必须是本次进程新写且持续刷新的状态,不能把旧启动遗留JSON当成健康。
valid = pids and stamp.st_mtime_ns != previous_status and stamp.st_mtime >= since - 0.5 \
and -2 <= time.time() - stamp.st_mtime <= 3 \
and status.get("owner") == "px_JKGL" and status.get("limitBytes") == 1024 ** 3 \
and status.get("failedWrites") == 0 and status.get("withinLimit") is True
except (FileNotFoundError, json.JSONDecodeError):
valid = False
if valid:
if stable_since is None:
stable_since = time.monotonic()
# 观察至少两秒稳定状态,不仅依据启动脚本退出码或瞬时PID。
if time.monotonic() - stable_since >= 2:
return {"collectorPids": pids, "nativeLogging": {name: status[name]
for name in ("owner", "limitBytes", "failedWrites", "withinLimit")}}
else:
stable_since = None
time.sleep(0.25)
# 图片接收/迁移是后续真实业务验证,此处不把进程健康当作图片已经到达。
raise RuntimeError("Candidate collector did not establish stable native log health")
def activate(args):
root, directory, record, checks, tests, launcher_hash = preflight(args)
if not args.apply:
# 默认仅给核验结果,不发送信号、不启动程序、不安装源码或配置。
print(json.dumps({"activationReady": True, "apply": False, "stage": str(directory),
"candidateBinarySha256": record["candidateBinarySha256"], "tests": tests}))
return
# 原日志状态文件保持原样,靠新写入校验;无需删除历史状态或日志文件。
status_path = root / "runtime/native-log-status.json"
old_status = status_path.stat().st_mtime_ns if status_path.exists() else None
changed = False
try:
stop_collector(root)
# changed在首次写入前置位,任何部分安装失败都进入对应stage自动回退。
changed = True
for relative in checks:
install(directory / "source" / relative, root / relative)
install(directory / "candidate-config.ini", root / "config.ini")
# 状态文件写入时间与启动前时刻同时比较,防止沿用上次进程的健康JSON。
since = time.time()
launch(root, directory, launcher_hash)
result = healthy(root, since, old_status, args.health_timeout)
result.update({"activated": True, "stage": str(directory), "tests": tests,
"candidateBinarySha256": record["candidateBinarySha256"]})
# 激活结果不包含配置正文或密钥;原图及迁移业务验收仍需PXHJ另行验证。
helper.write_json(directory / "activation-result.json", result)
print(json.dumps(result))
except BaseException as failure:
# 安装失败与业务切换失败都留下独立记录,root可辨别回退是否真正成功。
restored = False
try:
if changed:
# 回退工具先校验候选/原文件归属,不会覆盖无关后续变更。
command = [sys.executable, str(Path(__file__).with_name("Rollback-EmbeddedImageRoute.py")),
"--root", str(root), "--stage", str(directory), "--launcher", "start-network-simulation.sh", "--apply"]
with (directory / "activation-rollback.log").open("wb") as output:
subprocess.run(command, stdout=output, stderr=subprocess.STDOUT, check=True, timeout=45)
restored = True
elif not helper.process_ids(root):
# 尚未写文件时只启动原程序,不能为预检失败修改任何生产文件。
launch(root, directory, launcher_hash)
restored = True
except BaseException:
# 保留独立回退日志供root处理,不能把回退失败伪装成成功。
restored = False
helper.write_json(directory / "activation-error.json", {"activationFailed": True,
"rollbackSucceeded": restored, "reason": str(failure)})
raise RuntimeError("Activation failed; rollbackSucceeded=" + str(restored)
+ "; inspect private stage activation logs") from None
def main():
# --apply承接用户已经给出的部署授权,默认预览便于操作者核对具体stage。
# 不需要再次登录设备或改变SSH策略,所有执行仍在已确认板端本地进行。
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect"))
parser.add_argument("--stage", type=Path, required=True)
parser.add_argument("--apply", action="store_true")
parser.add_argument("--health-timeout", type=int, default=15)
args = parser.parse_args()
if not 3 <= args.health_timeout <= 60:
parser.error("health-timeout must be between 3 and 60 seconds")
activate(args)
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
[CmdletBinding()]
param([string]$PxhjRoot = 'E:\pxhj')
$ErrorActionPreference = 'Stop'
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$staging = Join-Path $projectRoot 'runtime/console-pxhj-staging'
$manifest = Get-Content -LiteralPath (Join-Path $staging 'baseline-manifest.json') -Raw | ConvertFrom-Json
$targetRoot = [IO.Path]::GetFullPath($PxhjRoot).TrimEnd('\')
$backupRoot = Join-Path $projectRoot ('runtime/console-bridge-backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
foreach ($entry in $manifest.files) {
$target = [IO.Path]::GetFullPath((Join-Path $targetRoot $entry.path))
if (-not $target.StartsWith($targetRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { throw '补丁路径超出目标项目。' }
$staged = Join-Path $staging $entry.path
if ((Get-FileHash -LiteralPath $staged -Algorithm SHA256).Hash -ne $entry.sha256) { throw ('补丁文件校验失败:' + $entry.path) }
if (Test-Path -LiteralPath $target) {
$currentHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash
$baseline = @($manifest.modifiedBaseline | Where-Object path -eq $entry.path)
if ($currentHash -ne $entry.sha256 -and ($baseline.Count -eq 0 -or $currentHash -ne $baseline[0].sha256)) { throw ('目标文件已变化,未覆盖:' + $entry.path) }
}
}
foreach ($entry in $manifest.files) {
$target = Join-Path $targetRoot $entry.path
if (Test-Path -LiteralPath $target) {
$backup = Join-Path $backupRoot $entry.path
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $backup) | Out-Null
Copy-Item -LiteralPath $target -Destination $backup
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
Copy-Item -LiteralPath (Join-Path $staging $entry.path) -Destination $target
}
Write-Output ('采集桥接源码已应用。备份目录:' + $backupRoot)
Write-Output '运行中的后端不会自动加载新源码。'
+20
View File
@@ -0,0 +1,20 @@
[CmdletBinding()]
param([string]$NodePath)
$ErrorActionPreference = 'Stop'
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$frontendRoot = Join-Path $projectRoot 'frontend'
if (-not $NodePath) {
$nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue
if ($nodeCommand) { $NodePath = $nodeCommand.Source }
}
if (-not $NodePath -or -not (Test-Path -LiteralPath $NodePath)) { throw '请安装 Node.js 20.19 或更新版本,或使用 -NodePath 指定 node.exe。' }
$vite = Join-Path $frontendRoot 'node_modules/vite/bin/vite.js'
if (-not (Test-Path -LiteralPath $vite)) { throw '缺少页面构建依赖。请先在 frontend 目录运行 pnpm install 或 npm install。' }
Push-Location $frontendRoot
try {
& $NodePath '--test' 'tests/data.test.mjs'
if ($LASTEXITCODE -ne 0) { throw '页面数据规则测试失败。' }
& $NodePath $vite 'build'
if ($LASTEXITCODE -ne 0) { throw '页面构建失败。' }
} finally { Pop-Location }
Write-Output '系统融合软件页面已构建到原生 JAR 静态资源目录。'
+18
View File
@@ -0,0 +1,18 @@
[CmdletBinding()]
param([string]$JavaHome, [string]$MavenPath, [string]$NodePath, [switch]$SkipFrontend)
. (Join-Path $PSScriptRoot 'Common.ps1')
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$java = Get-XtrhJava -JavaHome $JavaHome
$env:JAVA_HOME = Split-Path -Parent (Split-Path -Parent $java)
if (-not $MavenPath) {
$maven = Get-Command mvn.cmd -ErrorAction SilentlyContinue
if ($maven) { $MavenPath = $maven.Source }
}
if (-not $MavenPath -or -not (Test-Path -LiteralPath $MavenPath)) { throw '请通过 -MavenPath 指定 mvn.cmd,或将 Maven 加入 PATH。' }
if (-not $SkipFrontend) { & (Join-Path $PSScriptRoot 'Build-Console.ps1') -NodePath $NodePath }
Push-Location $projectRoot
try {
& $MavenPath '-B' 'verify'
if ($LASTEXITCODE -ne 0) { throw 'Java 构建或测试失败。' }
} finally { Pop-Location }
Write-Output ('构建完成:' + (Join-Path $projectRoot 'target/xtrh-services-1.0.0.jar'))
+69
View File
@@ -0,0 +1,69 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-XtrhJava {
param([string]$JavaHome)
if (-not $JavaHome) { $JavaHome = $env:JAVA_HOME }
if ($JavaHome) {
$candidate = Join-Path $JavaHome 'bin/java.exe'
if (Test-Path -LiteralPath $candidate) { return (Resolve-Path -LiteralPath $candidate).Path }
throw 'JAVA_HOME 中没有找到 bin/java.exe。'
}
$command = Get-Command java.exe -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
throw '请先安装 Java 17 或更新版本,并设置 JAVA_HOME。'
}
function Protect-XtrhPrivateFile {
param([Parameter(Mandatory)][string]$Path)
# 私有凭证只允许当前部署用户、SYSTEM 和本机管理员读取,避免共享目录泄露。
# 读取现有对象后仅修改访问规则;不改所有者和审计规则,普通部署用户也可执行。
$access = New-Object System.Security.AccessControl.FileSecurity($Path, [System.Security.AccessControl.AccessControlSections]::Access)
$access.SetAccessRuleProtection($true, $false)
foreach ($rule in $access.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) {
[void]$access.RemoveAccessRuleSpecific($rule)
}
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
foreach ($sid in @($identity, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')), (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))) {
$access.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')))
}
$file = Get-Item -LiteralPath $Path
if ($file.PSObject.Methods['SetAccessControl']) { $file.SetAccessControl($access) }
else { [System.IO.FileSystemAclExtensions]::SetAccessControl($file, $access) }
}
function Write-XtrhText {
param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content)
[System.IO.File]::WriteAllText($Path, $Content, (New-Object System.Text.UTF8Encoding($false)))
}
function ConvertTo-XtrhPropertyValue {
param([Parameter(Mandatory)][string]$Value)
# Java properties 的字符编码可能因加载器不同而变化,中文节点名用 Unicode 转义保存。
$builder = New-Object System.Text.StringBuilder
foreach ($character in $Value.ToCharArray()) {
if ([int]$character -gt 127) { [void]$builder.Append(('\u{0:x4}' -f [int]$character)) }
elseif ($character -eq '\') { [void]$builder.Append('\\') }
else { [void]$builder.Append($character) }
}
return $builder.ToString()
}
function Get-XtrhOwnedProcess {
param([Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)]$Record)
$process = Get-Process -Id ([int]$Record.pid) -ErrorAction SilentlyContinue
if (-not $process) { return $null }
$live = Get-CimInstance Win32_Process -Filter ('ProcessId=' + [int]$Record.pid)
$expectedJar = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot 'xtrh-services.jar'))
$javaPath = [System.IO.Path]::GetFullPath([string]$Record.javaPath)
# PID 会被操作系统复用;同时校验启动时间、Java 路径和 -jar 参数才能结束进程。
if (-not $live -or $process.StartTime.ToUniversalTime().Ticks -ne [long]$Record.startTicks -or
[System.IO.Path]::GetFullPath([string]$live.ExecutablePath) -ne $javaPath) {
throw '进程身份不匹配;已拒绝结束该 PID。'
}
$match = [regex]::Match([string]$live.CommandLine, '(?i)(?:^|\s)-jar\s+(?:"(?<jar>[^"]+)"|(?<jar>\S+))')
if (-not $match.Success -or [System.IO.Path]::GetFullPath($match.Groups['jar'].Value) -ne $expectedJar) {
throw '进程启动文件与本部署目录不一致;已拒绝结束该 PID。'
}
return $process
}
+32
View File
@@ -0,0 +1,32 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$PxhjRoot,
[Parameter(Mandatory)][string]$CredentialFile,
[string]$CoordinatorUrl = 'http://127.0.0.1:18100',
[string]$UdpTargetHost = '127.0.0.1',
[switch]$Force
)
. (Join-Path $PSScriptRoot 'Common.ps1')
$address = $null
if (-not [Uri]::TryCreate($CoordinatorUrl, [UriKind]::Absolute, [ref]$address) -or $address.Scheme -notin @('http', 'https') -or
-not $address.Host -or $address.UserInfo -or $address.Query -or $address.Fragment -or $address.AbsolutePath -ne '/') {
throw 'CoordinatorUrl 必须是完整的总控根地址。'
}
if ($UdpTargetHost -notmatch '^[a-zA-Z0-9_.:-]{1,200}$') { throw 'UdpTargetHost 必须是网关能够访问的 PXHJ 接收地址。' }
$root = [System.IO.Path]::GetFullPath($PxhjRoot)
if (-not (Test-Path -LiteralPath (Join-Path $root 'backend/src/main/resources/application.yml'))) { throw 'PxhjRoot 中没有找到 PXHJ 后端配置。' }
$credentialContent = Get-Content -LiteralPath $CredentialFile -Raw
$tokenMatch = [regex]::Match($credentialContent, '(?m)^fusion\.token=([^\r\n]{24,})\r?$')
$clusterMatch = [regex]::Match($credentialContent, '(?m)^fusion\.cluster-id=([a-zA-Z0-9_-]{1,64})\r?$')
if (-not $tokenMatch.Success -or -not $clusterMatch.Success) { throw '集群凭证文件格式不正确。' }
$configFolder = Join-Path $root 'config'
$privateFile = Join-Path $configFolder 'fusion.local.properties'
if ((Test-Path -LiteralPath $privateFile) -and -not $Force) { throw 'PXHJ 私有融合配置已存在;需要覆盖时显式使用 -Force。' }
[System.IO.Directory]::CreateDirectory($configFolder) | Out-Null
$content = @('app.fusion.enabled=true', ('app.fusion.coordinator-url=' + $CoordinatorUrl.TrimEnd('/')),
('app.fusion.cluster-id=' + $clusterMatch.Groups[1].Value), ('app.fusion.token=' + $tokenMatch.Groups[1].Value),
('app.fusion.udp-target-host=' + $UdpTargetHost)) -join "`n"
Write-XtrhText -Path $privateFile -Content ($content + "`n")
Protect-XtrhPrivateFile -Path $privateFile
# 只生成私有桥接配置,不擅自重启正在运行的 PXHJ 或修改采集器上传地址。
Write-Output ('PXHJ 总控连接配置已写入:' + $privateFile + '。后端重启后加载。')
+89
View File
@@ -0,0 +1,89 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Initialize-XtrhImageVerification {
param([Parameter(Mandatory)][string]$CredentialFile)
# 私有文件只读入内存。日志、stdout、命令参数和联调记录都不携带凭证正文。
$privateText = [IO.File]::ReadAllText([IO.Path]::GetFullPath($CredentialFile))
$match = [regex]::Match($privateText, '(?m)^fusion\.token\s*=\s*([^\r\n]+)')
if (-not $match.Success -or $match.Groups[1].Value.Trim().Length -lt 16) { throw '集群私有凭证文件无效。' }
$script:imageVerificationCredential = $match.Groups[1].Value.Trim()
$handler = [Net.Http.HttpClientHandler]::new()
$handler.AllowAutoRedirect = $false
$handler.UseCookies = $false
$script:imageVerificationClient = [Net.Http.HttpClient]::new($handler, $true)
$script:imageVerificationClient.Timeout = [TimeSpan]::FromSeconds(5)
$script:imageVerificationClient.MaxResponseContentBufferSize = 8 * 1024 * 1024
}
function Get-XtrhImageDigest {
param([Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes)
$sha = [Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Get-XtrhImageSignature {
param([Parameter(Mandatory)][string]$Canonical)
$mac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($script:imageVerificationCredential))
try { return [BitConverter]::ToString($mac.ComputeHash([Text.Encoding]::UTF8.GetBytes($Canonical))).Replace('-', '').ToLowerInvariant() }
finally { $mac.Dispose() }
}
function Invoke-XtrhImageSigned {
param([Parameter(Mandatory)][string]$BaseUrl, [Parameter(Mandatory)][string]$Path,
[ValidateSet('GET','POST','PATCH')][string]$Method = 'GET', $Payload = $null)
$base = [Uri]$BaseUrl
if ($base.Scheme -notin @('http','https') -or $base.UserInfo -or $base.Query -or $base.Fragment -or $base.AbsolutePath -ne '/') {
throw '服务根地址格式无效。'
}
if ($Path -notmatch '^/' -or $Path -match '[\r\n]') { throw '签名接口路径无效。' }
# 空数组不能通过 PowerShell 表达式管道返回,否则会被展开为 null。
[byte[]]$body = [byte[]]::new(0)
if ($null -ne $Payload) { $body = [Text.Encoding]::UTF8.GetBytes(($Payload | ConvertTo-Json -Depth 12 -Compress)) }
$nonce = [Guid]::NewGuid().ToString()
$time = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString()
$digest = Get-XtrhImageDigest -Bytes $body
$signature = Get-XtrhImageSignature -Canonical "request`n$Method`n$Path`n$time`n$nonce`n$digest"
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $BaseUrl.TrimEnd('/') + $Path)
try {
[void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Time', $time)
[void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Nonce', $nonce)
[void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Signature', $signature)
$request.Content = [Net.Http.ByteArrayContent]::new($body)
$request.Content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new('application/json')
try { $response = $script:imageVerificationClient.SendAsync($request).GetAwaiter().GetResult() }
catch { throw '服务请求失败,未输出私有请求头或响应内容。' }
try {
[byte[]]$bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$status = [int]$response.StatusCode
$expected = Get-XtrhImageSignature -Canonical ("response`n$nonce`n$status`n" + (Get-XtrhImageDigest -Bytes $bytes))
$headers = [Collections.Generic.IEnumerable[string]]$null
if (-not $response.Headers.TryGetValues('X-Xtrh-Response-Signature', [ref]$headers)) { throw '响应缺少服务身份签名。' }
$actual = @($headers)[0]
# 固定长度与逐字节累计比较;JPEG 必须签其原始字节,不能按文本转换。
$different = $actual.Length -bxor $expected.Length
if ($actual.Length -eq $expected.Length) {
for ($i = 0; $i -lt $expected.Length; $i++) { $different = $different -bor ([int]$actual[$i] -bxor [int]$expected[$i]) }
}
if ($different -ne 0) { throw '响应服务身份认证失败。' }
return [pscustomobject]@{ Status = $status; Bytes = $bytes; ContentType = [string]$response.Content.Headers.ContentType }
} finally { $response.Dispose() }
} finally { $request.Dispose() }
}
function ConvertFrom-XtrhImageJson {
param([Parameter(Mandatory)]$Response)
if ($Response.Status -ne 200) { throw ('签名服务接口返回 HTTP ' + $Response.Status) }
return [Text.Encoding]::UTF8.GetString($Response.Bytes) | ConvertFrom-Json
}
function Get-XtrhImageSnapshot {
param([Parameter(Mandatory)][string]$CoordinatorUrl)
return ConvertFrom-XtrhImageJson (Invoke-XtrhImageSigned -BaseUrl $CoordinatorUrl -Path '/control/snapshot')
}
function Close-XtrhImageVerification {
if ($null -ne $script:imageVerificationClient) { $script:imageVerificationClient.Dispose() }
$script:imageVerificationCredential = ''
}
+69
View File
@@ -0,0 +1,69 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('coordinator', 'gateway', 'worker')][string]$Role,
[Parameter(Mandatory)][string]$InstallRoot,
[Parameter(Mandatory)][string]$CredentialFile,
[string]$JarPath,
[int]$Port = 0,
[int]$Capacity = 2,
[int]$UdpPort = 19002,
[int]$ImagePort = 0,
[string]$NodeName = '',
[string]$AdvertisedHost = '',
[string]$DiscoveryAddress = '',
[string]$CoordinatorUrl = '',
[switch]$CoordinatorOnly
)
. (Join-Path $PSScriptRoot 'Common.ps1')
if ($CoordinatorOnly -and $Role -ne 'coordinator') { throw 'CoordinatorOnly 只能用于 coordinator 角色。' }
if ($Capacity -lt 0 -or $Capacity -gt 64) { throw 'Capacity 必须在 0 到 64 之间。' }
if ($UdpPort -lt 1025 -or $UdpPort -gt 65535) { throw 'UdpPort 必须在 1025 到 65535 之间。' }
if (-not $Port) { $Port = @{coordinator = 18100; gateway = 18110; worker = 18120}[$Role] }
if ($Port -lt 1025 -or $Port -gt 65535) { throw 'Port 必须在 1025 到 65535 之间。' }
if ($Role -ne 'gateway' -and -not $CoordinatorOnly) {
# 图片直达执行节点;同机多实例采用不同入口,避免争抢 PXHJ 原有 6020。
if (-not $ImagePort) { $ImagePort = $Port + 1000 }
if ($ImagePort -lt 1025 -or $ImagePort -gt 65535) { throw 'ImagePort 必须在 1025 到 65535 之间。' }
}
foreach ($value in @($NodeName, $AdvertisedHost, $DiscoveryAddress, $CoordinatorUrl)) {
if ($value -match '[\r\n]') { throw '配置值不能包含换行。' }
}
if ($CoordinatorUrl -and $CoordinatorUrl -notmatch '^https?://[^\s/?#]+/?$') { throw 'CoordinatorUrl 必须为服务根地址。' }
if (-not $JarPath) { $JarPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'target/xtrh-services-1.0.0.jar' }
if (-not (Test-Path -LiteralPath $JarPath)) { throw '未找到构建产物,请先运行 Build-Xtrh.ps1。' }
if (-not (Test-Path -LiteralPath $CredentialFile)) { throw '未找到私有集群凭证文件。' }
$root = [System.IO.Path]::GetFullPath($InstallRoot)
$processFile = Join-Path $root 'service-process.json'
if (Test-Path -LiteralPath $processFile) {
$record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json
if (Get-XtrhOwnedProcess -InstallRoot $root -Record $record) { throw '部署实例正在运行,请先运行 Stop-Xtrh.ps1。' }
}
$credentialContent = Get-Content -LiteralPath $CredentialFile -Raw
if ($credentialContent -notmatch '(?m)^fusion\.token=.{24,}$' -or $credentialContent -notmatch '(?m)^fusion\.cluster-id=[a-zA-Z0-9_-]{1,64}\r?$') {
throw '集群凭证文件内容不符合格式。'
}
foreach ($folder in @($root, (Join-Path $root 'config'), (Join-Path $root 'logs'), (Join-Path $root 'data'), (Join-Path $root 'scripts'))) {
[System.IO.Directory]::CreateDirectory($folder) | Out-Null
}
Copy-Item -LiteralPath $JarPath -Destination (Join-Path $root 'xtrh-services.jar') -Force
$privateFile = Join-Path $root 'config/cluster.local.properties'
Write-XtrhText -Path $privateFile -Content $credentialContent
Protect-XtrhPrivateFile -Path $privateFile
$lines = @('server.address=0.0.0.0', "server.port=$Port", "fusion.role=$Role", 'fusion.data-dir=./data',
"fusion.capacity=$Capacity", "fusion.gateway-port=$UdpPort", 'fusion.discovery-enabled=true')
if ($Role -eq 'coordinator') {
# 总控默认在同一个进程中执行任务;只调度模式需显式关闭执行能力。
$lines += ('fusion.coordinator-worker-enabled=' + (-not $CoordinatorOnly).ToString().ToLowerInvariant())
}
if ($Role -ne 'gateway' -and -not $CoordinatorOnly) { $lines += "fusion.image-port=$ImagePort" }
if ($NodeName) { $lines += ('fusion.node-name=' + (ConvertTo-XtrhPropertyValue -Value $NodeName)) }
if ($AdvertisedHost) { $lines += "fusion.advertised-host=$AdvertisedHost" }
if ($DiscoveryAddress) { $lines += "fusion.discovery-address=$DiscoveryAddress" }
# 默认没有固定总控地址,节点在局域网自动发现;跨网段时才使用统一域名兜底。
if ($CoordinatorUrl) { $lines += "fusion.coordinator-url=$CoordinatorUrl" }
Write-XtrhText -Path (Join-Path $root 'config/service.local.properties') -Content (($lines -join "`n") + "`n")
Write-XtrhText -Path (Join-Path $root 'deployment.json') -Content (@{role = $Role; port = $Port} | ConvertTo-Json)
foreach ($name in @('Common.ps1', 'Start-Xtrh.ps1', 'Stop-Xtrh.ps1', 'Register-XtrhStartup.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $root "scripts/$name") -Force
}
Write-Output ('部署完成:' + $root + ',角色:' + $Role + ',HTTP 端口:' + $Port)
+26
View File
@@ -0,0 +1,26 @@
[CmdletBinding()]
param(
[string[]]$SourceRoots = @((Join-Path (Split-Path -Parent $PSScriptRoot) 'src/main/java')),
[double]$MinimumPercent = 20,
[switch]$Detailed
)
$ErrorActionPreference = 'Stop'
$rows = foreach ($root in $SourceRoots) {
foreach ($file in Get-ChildItem -LiteralPath $root -Filter '*.java' -File -Recurse) {
$lines = [IO.File]::ReadAllLines($file.FullName)
$nonEmpty = @($lines | Where-Object { $_.Trim().Length -gt 0 })
$comments = @($nonEmpty | Where-Object { $_ -match '^\s*(//|/\*|\*|\*/)' })
[pscustomobject]@{
File = $file.FullName
NonEmpty = $nonEmpty.Count
CommentLines = $comments.Count
Percent = if ($nonEmpty.Count) { [Math]::Round(100.0 * $comments.Count / $nonEmpty.Count, 2) } else { 0 }
}
}
}
$total = ($rows | Measure-Object NonEmpty -Sum).Sum
$commentTotal = ($rows | Measure-Object CommentLines -Sum).Sum
$percent = if ($total) { 100.0 * $commentTotal / $total } else { 0 }
if ($Detailed) { $rows | Format-Table -AutoSize }
Write-Output ('Java 注释占比:{0:N2}%({1} 注释行 / {2} 非空源码行)' -f $percent, $commentTotal, $total)
if ($percent -lt $MinimumPercent) { throw ('尚未达到约定的 {0}% 注释行要求。' -f $MinimumPercent) }
+19
View File
@@ -0,0 +1,19 @@
[CmdletBinding()]
param([Parameter(Mandatory)][string]$CredentialFile, [string]$ClusterId = 'pxhj-fusion')
. (Join-Path $PSScriptRoot 'Common.ps1')
if ($ClusterId -notmatch '^[a-zA-Z0-9_-]{1,64}$') { throw 'ClusterId 仅允许字母、数字、下划线和短横线。' }
$credentialPath = [System.IO.Path]::GetFullPath($CredentialFile)
if (Test-Path -LiteralPath $credentialPath) { throw '凭证文件已存在;不会覆盖现有集群凭证。' }
[System.IO.Directory]::CreateDirectory((Split-Path -Parent $credentialPath)) | Out-Null
$bytes = New-Object byte[] 32
$random = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $random.GetBytes($bytes) } finally { $random.Dispose() }
$credential = [Convert]::ToBase64String($bytes)
$stream = [System.IO.File]::Open($credentialPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
try {
$content = [System.Text.Encoding]::UTF8.GetBytes("fusion.cluster-id=$ClusterId`nfusion.token=$credential`n")
$stream.Write($content, 0, $content.Length)
} finally { $stream.Dispose() }
Protect-XtrhPrivateFile -Path $credentialPath
# 凭证内容不输出、不放进命令行;将此私有文件经可信方式复制到同一集群的部署电脑。
Write-Output ('集群凭证已生成并限制访问:' + $credentialPath)
+207
View File
@@ -0,0 +1,207 @@
[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 '部分原容量未恢复,请按保存的迁移计划处理。' }
}
+12
View File
@@ -0,0 +1,12 @@
[CmdletBinding()]
param([string]$InstallRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/coordinator'), [string]$JavaHome, [switch]$OpenBrowser)
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($InstallRoot)
$deployment = Get-Content -LiteralPath (Join-Path $root 'deployment.json') -Raw | ConvertFrom-Json
if ($deployment.role -ne 'coordinator') { throw '页面入口需要总控部署目录。' }
& (Join-Path $PSScriptRoot 'Start-Xtrh.ps1') -InstallRoot $root -JavaHome $JavaHome
$url = 'http://127.0.0.1:' + $deployment.port + '/console/#receive'
$page = Invoke-WebRequest -UseBasicParsing -Uri $url.Split('#')[0] -TimeoutSec 5
if ([int]$page.StatusCode -ne 200) { throw '页面未就绪,请先部署带页面的新版本。' }
Write-Output ('系统融合软件:' + $url)
if ($OpenBrowser) { Start-Process $url }
+24
View File
@@ -0,0 +1,24 @@
[CmdletBinding()]
param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot), [string]$JavaHome, [switch]$Remove)
. (Join-Path $PSScriptRoot 'Common.ps1')
$root = [System.IO.Path]::GetFullPath($InstallRoot)
$sha = [System.Security.Cryptography.SHA256]::Create()
try { $hash = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-', '').Substring(0, 12) }
finally { $sha.Dispose() }
$taskName = 'PXHJ-XTRH-' + $hash
if ($Remove) {
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false }
Write-Output ('已移除此部署实例的自动启动:' + $taskName)
return
}
$java = Get-XtrhJava -JavaHome $JavaHome
$resolvedJavaHome = Split-Path -Parent (Split-Path -Parent $java)
$startScript = Join-Path $root 'scripts/Start-Xtrh.ps1'
$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $startScript + '" -InstallRoot "' + $root + '" -JavaHome "' + $resolvedJavaHome + '"'
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments -WorkingDirectory $root
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
Write-Output ('已配置当前用户登录后后台自动启动:' + $taskName)
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""回退指定图片协商stage;默认只检查,只有显式--apply才由操作者在板上执行。"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
import uuid
# 复用相同路径/哈希校验,不引入SSH、密码或远端自动连接。
loader = importlib.util.spec_from_file_location("collector_stage", Path(__file__).with_name("Stage-EmbeddedImageRoute.py"))
helper = importlib.util.module_from_spec(loader)
loader.loader.exec_module(helper)
def atomic_restore(source, destination, root):
# 临时文件必须和目的文件同目录,os.replace不会跨文件系统失去原子性。
# 禁止符号链接目的地或借父目录链接逃出当前采集工程。
if destination.is_symlink() or not destination.parent.resolve().is_relative_to(root):
raise RuntimeError("Unsafe rollback target")
temporary = destination.with_name(destination.name + ".rollback-" + uuid.uuid4().hex)
shutil.copy2(source, temporary)
os.replace(str(temporary), str(destination))
def main():
# 回退授权来自本次部署操作,--apply是动作入口,不是新的用户审批。
# root可以先审阅默认检查输出,再按既有授权直接执行。
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect"))
parser.add_argument("--stage", type=Path, required=True)
parser.add_argument("--launcher", choices=("start-network-simulation.sh", "start.sh"), default="start-network-simulation.sh")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
root, directory, record = helper.stage_record(args)
# 使用板上当前原启动器,不重写串口桥或PID管理逻辑。
launcher = root / args.launcher
if launcher.is_symlink() or not launcher.is_file() or not os.access(str(launcher), os.X_OK):
raise RuntimeError("Original collector launcher is invalid")
if not record.get("built"):
raise RuntimeError("Stage has no successfully built candidate")
# 完整原配置快照恢复JPEG质量、帧率及其他原设置,不只把协商开关关掉。
# 设备密钥不在此备份范围,回退不能替换或改写密钥。
backups = dict(record["previousPatchHashes"])
backups.update({"px_Collect.pro": record["previousProjectSha256"],
"bin/px_JKGL": record["previousBinarySha256"], "config.ini": record["previousConfigSha256"]})
# 不凭文件名认为当前版本属于本次部署,每项都要匹配原版或候选哈希。
candidates = dict(record["candidatePatchHashes"])
candidates.update({"px_Collect.pro": record["candidateProjectSha256"],
"bin/px_JKGL": record["candidateBinarySha256"], "config.ini": record["candidateConfigSha256"]})
# 只允许回退本stage候选或原版本;其他人后续更新的版本不能被历史备份覆盖。
# 先校验全部文件和备份,再停止进程,不能边校验边执行部分回退。
for relative, previous in backups.items():
live = root / relative
if live.is_symlink() or not live.parent.resolve().is_relative_to(root):
raise RuntimeError("Unsafe live path: " + relative)
current = helper.digest(live) if live.is_file() else None
if current not in (previous, candidates[relative]):
raise RuntimeError("Unrelated live update detected: " + relative)
preserved = directory / "backup" / relative
if previous is not None and (preserved.is_symlink() or not preserved.resolve().is_relative_to(directory)
or not preserved.is_file() or helper.digest(preserved) != previous):
raise RuntimeError("Rollback backup hash mismatch: " + relative)
for relative, checksum in record["preservedHashes"].items():
if helper.digest(root / relative) != checksum:
raise RuntimeError("Unrelated preserved source changed: " + relative)
# 默认只输出非敏感回退范围,审阅时不会停进程或读取密钥。
if not args.apply:
print(json.dumps({"rollbackReady": True, "apply": False, "stage": str(directory),
"collectorPids": helper.process_ids(root), "files": list(backups)}))
return
# 进程列表使用真实exe路径,当前串口桥即使同属工程也不会被停止。
original_pids = helper.process_ids(root)
# 仅停止真实exe路径指向本工程的采集程序,不动串口桥、系统服务或其他同名进程。
for pid in original_pids:
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + 5
while set(original_pids) & set(helper.process_ids(root)) and time.monotonic() < deadline:
time.sleep(0.1)
for pid in set(original_pids) & set(helper.process_ids(root)):
os.kill(pid, signal.SIGKILL)
time.sleep(0.1)
if helper.process_ids(root):
# 自动守护重启需要操作者处理其owner,工具不擅自停systemd或改变开机项。
raise RuntimeError("Collector restarted by another owner; live files were not replaced")
# 先确认原进程全部退出,再原子替换文件,避免恢复过程中旧程序继续发包。
for relative, previous in backups.items():
live = root / relative
if previous is None:
# 只移除本stage新增且哈希匹配的源码文件;不递归删除任何目录。
if live.exists():
live.unlink()
else:
live.parent.mkdir(parents=True, exist_ok=True)
atomic_restore(directory / "backup" / relative, live, root)
# 使用当前已核对的启动入口,保留原native日志策略,不恢复旧run.log重定向脚本。
# 原启动脚本负责库路径和offscreen参数,回退不修改任何启动脚本内容。
# stdout/stderr交给原native日志接管,禁止再次追加到无界run.log。
child = None
if args.launcher == "start.sh":
child = subprocess.Popen([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
else:
# 网络模拟启动器自管collector.pid,不能把短命shell的PID写成采集PID。
subprocess.run([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=15)
time.sleep(2)
running = helper.process_ids(root)
if (child is not None and child.poll() is not None) or not running:
raise RuntimeError("Original collector could not restart; backups remain available")
# 原网络模拟启动器识别PID文件,避免下一次调用误启动第二个采集进程。
pid_file = root / "logs/collector.pid"
if child is not None and pid_file.exists() and not pid_file.is_symlink():
pid_file.write_text(str(child.pid) + "\n", encoding="ascii")
# 此处只确认原进程重新出现,真实图片/采集/试验链路由root继续验证。
print(json.dumps({"rolledBack": True, "collectorPids": running, "stage": str(directory)}))
if __name__ == "__main__":
main()
+443
View File
@@ -0,0 +1,443 @@
#!/usr/bin/env python3
"""准备嵌入式图片协商候选版本;本工具不停止进程、不替换在用程序、不修改在用配置。"""
import argparse
import configparser
import hashlib
import io
import ipaddress
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import tarfile
import time
import uuid
# 本次仅交付图片地址协商代码;其他业务源码必须以当前开发板为基线。
# 不整份上传历史工程,避免覆盖最新日志保留、串口或RTP收流修改。
PATCH_FILES = (
"util/PlatformWireCodec.h", "util/PlatformWireCodec.cpp",
"util/PlatformImageRouteClient.h", "util/PlatformImageRouteClient.cpp",
"util/PlatformImageSender.h", "util/PlatformImageSender.cpp",
)
# 测试只运行临时目录与loopback,不使用实际设备密钥和采集端口。
TEST_FILES = (
"tests/image_route/native_image_route_check.pro",
"tests/image_route/native_image_route_check.cpp",
"tests/logging/native_logging_check.pro",
"tests/logging/native_logging_check.cpp",
)
ALLOWED = set(PATCH_FILES + TEST_FILES)
# 这些文件只记录哈希,以验证部署保留现有功能;本工具不会覆盖它们。
PRESERVED = (
"main.cpp", "util/log.cpp", "util/log.h", "util/CollectorLogRetention.cpp",
"util/CollectorLogRetention.h", "start.sh",
"Go2Controller/network/rtpreceiver.cpp", "Fly2Controller/network/RtpReceiver.cpp",
"util/ModuleVideoReceiver.cpp", "CJ2Controller/serialworker.cpp", "mainwindow.cpp",
)
def digest(path):
# 分块计算,避免相机SDK等大文件一次读入内存。
value = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
value.update(block)
return value.hexdigest()
def write_json(path, value):
# 部署记录留在私有stage;不输出配置全文、密钥文件或进程环境。
path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
def package(args):
# 使用当前工作空间源码,禁止调用旧prepare-board-deployment脚本重建全量包。
# 包只携带本次代码与测试,不携带平台IP、上传配置、密钥或旧可执行文件。
# 压缩包不含旧可执行文件,必须经实际ARM工具链重新构建。
# 不读取本机现网INI,候选IP必须在板端stage时显式指定。
source = args.source.resolve(strict=True)
# 同名产物不覆盖,方便将每次候选与原始校验记录一一对应。
if args.package.exists():
raise RuntimeError("Package already exists; choose a new output filename")
args.package.parent.mkdir(parents=True, exist_ok=True)
manifest = {"protocol": "COLLECTOR_IMAGE_ROUTE", "format": 1, "files": []}
# 精确白名单不包含任何ini/密钥/运行目录,也不递归打包未知文件。
with tarfile.open(str(args.package), "w:gz") as archive:
for relative in PATCH_FILES + TEST_FILES:
path = source / relative
if path.is_symlink() or not path.resolve(strict=True).is_relative_to(source):
raise RuntimeError("Unexpected source path: " + relative)
data = path.read_bytes()
item = tarfile.TarInfo(relative)
item.size = len(data)
item.mode = 0o644
archive.addfile(item, io.BytesIO(data))
manifest["files"].append({"path": relative, "sha256": hashlib.sha256(data).hexdigest()})
data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
item = tarfile.TarInfo("image-route-manifest.json")
item.size = len(data)
item.mode = 0o644
archive.addfile(item, io.BytesIO(data))
print(json.dumps({"package": str(args.package.resolve()), "files": len(ALLOWED),
"sha256": digest(args.package)}, ensure_ascii=False))
def live_root(args):
# 路径必须是已存在工程,禁止根目录及符号链接冒充部署目录。
if args.root.is_symlink():
raise RuntimeError("Collector root cannot be a symbolic link")
# 所有后续路径由规范化根目录拼接,不接收任意远端目标路径。
root = args.root.resolve(strict=True)
if root == Path(root.anchor) or not (root / "px_Collect.pro").is_file():
raise RuntimeError("Collector project root is invalid")
return root
def process_ids(root):
target = str(root / "bin/px_JKGL")
# 没有/proc时只用于本地安全测试,不声称发现了实际板端进程。
matches = []
# 仅核对可执行文件真实路径,不用进程名字匹配其他同名程序。
for entry in Path("/proc").iterdir() if Path("/proc").is_dir() else []:
if not entry.name.isdigit():
continue
try:
if os.readlink(str(entry / "exe")).replace(" (deleted)", "") == target:
matches.append(int(entry.name))
except (FileNotFoundError, PermissionError):
pass
return sorted(matches)
def inspect(args):
# 不主动建立SSH,操作者负责将工具上传到已确认的目标板再运行。
# inspect不改网络、时钟、配置、库或进程,输出可供root比对的哈希。
# 输出源码哈希供当前板与本机差异核对,不能把历史记录当成现网事实。
root = live_root(args)
# 地址和目标路径来自root确认,inspect只给非敏感状态与源码哈希。
print(json.dumps({"root": str(root), "architecture": platform.machine(),
"collectorPids": process_ids(root), "binaryPresent": (root / "bin/px_JKGL").is_file(),
"qmake": shutil.which("qmake"), "make": shutil.which("make"),
"preservedSources": {name: digest(root / name) for name in PRESERVED if (root / name).is_file()},
"sourceHashes": {name: digest(root / name) if (root / name).is_file() else None for name in PATCH_FILES},
"projectSha256": digest(root / "px_Collect.pro")}, ensure_ascii=False, indent=2))
def ignore_files(directory, names):
# 不在stage复制在用配置和密钥;测试自行生成临时密钥。
# 对bin/runtime/logs/build的排除也避免复制采集历史和巨量日志。
ignored = []
for name in names:
lower = name.lower()
if name in {"bin", "runtime", "logs", "build", "debug", "release", ".git", "__pycache__"} \
or name.startswith(("Makefile", "moc_", ".qmake")) \
or lower.endswith((".ini", ".o", ".obj", ".user", ".pyc")) \
or ("key" in lower and lower.endswith((".json", ".properties"))):
ignored.append(name)
return ignored
def payload(args):
# 逐个读取白名单文件,不调用tar.extract,拒绝目录穿越、链接或重复条目。
# 文件总数和每项大小均受限,未知条目即使名称看似安全也不接受。
with tarfile.open(str(args.package), "r:gz") as archive:
names = archive.getnames()
if len(names) != len(set(names)) or set(names) != ALLOWED | {"image-route-manifest.json"}:
raise RuntimeError("Unexpected archive contents")
content = {}
for name in names:
item = archive.getmember(name)
if not item.isfile() or item.size <= 0 or item.size > 2 * 1024 * 1024:
raise RuntimeError("Invalid archive entry: " + name)
content[name] = archive.extractfile(item).read()
# manifest也受大小和类型约束,只有白名单完整且逐文件哈希匹配才继续。
manifest = json.loads(content.pop("image-route-manifest.json"))
if manifest.get("protocol") != "COLLECTOR_IMAGE_ROUTE" or manifest.get("format") != 1:
raise RuntimeError("Unknown package format")
if len(manifest.get("files", [])) != len(ALLOWED):
raise RuntimeError("Manifest entry count is invalid")
checks = {item["path"]: item["sha256"] for item in manifest["files"]}
if set(checks) != ALLOWED or any(hashlib.sha256(data).hexdigest() != checks[name]
for name, data in content.items()):
raise RuntimeError("Package hash validation failed")
return content
def project_append(project):
text = project.read_text(encoding="utf-8")
# 保留板上已有依赖/DEFINES,尤其GST、日志保留与hiredis真实库位置。
# 仅补缺少的HEADERS/SOURCES,不拿本机历史pro覆盖当前ARM工程。
# 注释提到文件名不算真实注册;追加动作仅发生在stage副本。
uncommented = "\n".join(line.split("#", 1)[0] for line in text.splitlines())
# 六个编译单元的类型由白名单后缀决定,不接受manifest提供qmake指令。
additions = []
for relative in PATCH_FILES:
if not re.search(r"(?<![A-Za-z0-9_])" + re.escape(relative) + r"(?![A-Za-z0-9_.])", uncommented):
additions.append(("HEADERS" if relative.endswith(".h") else "SOURCES") + " += " + relative)
if additions:
text += "\n# 图片上传地址协商:仅追加编译单元,保留当前板端全部原有依赖。\n"
text += "\n".join(additions) + "\n"
project.write_text(text, encoding="utf-8")
def candidate_config(original, gateway, gateway_port, fallback, fallback_port):
# 使用严格解析检查重复节/键,再只编辑指定键,保留其他值、注释和大小写。
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read_string(original)
except configparser.Error:
# INI解析错误可能包含原配置行,禁止把潜在凭证写入部署日志。
raise RuntimeError("Existing collector INI has unsupported or duplicate entries") from None
if not parser.getboolean("image_upload", "enabled", fallback=False):
raise RuntimeError("Existing image_upload must already be enabled")
# 协商target由可信回复动态决定,此处不写节点IP,也不固定每台节点的路数。
# 独立fallback_ip是部署必填项,避免fixed_json指向网关后继承错误地址。
changes = {"image_upload": {"udp_host": fallback, "udp_port": str(fallback_port)},
"image_negotiation": {"enabled": "true", "gateway_host": gateway,
"gateway_port": str(gateway_port)}}
current = None
result = []
# seen区分已有节与真正缺失的节,避免生成会被QSettings错误解释的重复配置。
seen = set()
def missing():
# 原节结束前补缺项,不追加重复节;不会重写fixed_json、heartbeat或RTP。
if current in changes:
for key, value in changes[current].items():
result.append(key + "=" + value)
changes[current].clear()
# 逐行保留未修改设置,避免ConfigParser整份重写丢失现网注释和自定义字段。
for line in original.splitlines():
section = re.match(r"^\s*\[([^]]+)\]\s*(?:[;#].*)?$", line)
if section:
missing()
current = section.group(1)
seen.add(current)
entry = re.match(r"^\s*([^=;#]+?)\s*=", line)
key = entry.group(1).strip() if entry else None
if current in changes and key in changes[current]:
line = key + "=" + changes[current].pop(key)
result.append(line)
missing()
for section, values in changes.items():
if values and section not in seen:
result += ["", "; 节点地址失效回退PXHJ原图入口,不能借用网关作为fallback。", "[" + section + "]"]
result += [key + "=" + value for key, value in values.items()]
# 配置只返回字符串,不在此函数触碰生产config.ini。
return "\n".join(result) + "\n"
def stage(args):
root = live_root(args)
archive = payload(args)
# 默认stage落在工程runtime内且使用唯一ID;已有目录绝不覆盖或递归删除。
parent = root / "runtime"
parent.mkdir(exist_ok=True)
if parent.is_symlink():
raise RuntimeError("Runtime directory cannot be a symbolic link")
directory = parent / ("image-route-stage-" + time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8])
# stage私有权限保护原配置备份;候选config另设600权限。
directory.mkdir(mode=0o700)
source = directory / "source"
# 由当前板端源码和SDK创建隔离副本,不在生产源目录做增量make。
# 保留相对库链接;后续所有写入路径还会核对没有经过链接逃出stage。
shutil.copytree(root, source, symlinks=True, ignore=ignore_files)
required = {"main.cpp": "CollectorLogRetention::install",
"px_Collect.pro": "COLLECTOR_WITH_GST",
"Go2Controller/network/rtpreceiver.cpp": "PlatformImageSender",
"Fly2Controller/network/RtpReceiver.cpp": "PlatformImageSender"}
# 发现旧工程缺少既有内置日志或JPG收流链路时停止,不能静默退回旧功能。
# 操作者需先审阅差异并合并当前源码,不能让本工具盲目覆盖main或RTP文件。
for relative, marker in required.items():
path = source / relative
if not path.is_file() or marker not in path.read_text(encoding="utf-8"):
raise RuntimeError("Required existing feature missing: " + relative)
# 记录当前板的受保留源码,build再次确认副本没有被覆盖为旧版本。
baseline = {name: digest(root / name) for name in PRESERVED if (root / name).is_file()}
# stage失败时留存证据目录,不自动删除备份或任何生产文件。
previous = {name: digest(root / name) if (root / name).is_file() else None for name in PATCH_FILES}
backup = directory / "backup"
backup.mkdir(mode=0o700)
# 切换前回退材料提前保存;备份config为私有文件,不打印其中任何内容。
for relative in PATCH_FILES + ("px_Collect.pro", "bin/px_JKGL", "config.ini"):
live = root / relative
if live.is_symlink() or not live.parent.resolve().is_relative_to(root):
raise RuntimeError("Unexpected live symbolic link: " + relative)
if live.is_file():
destination = backup / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(live, destination)
os.chmod(str(backup / "config.ini"), 0o600)
# 仅写副本白名单;在用util源码保持原样直到root执行已授权切换。
for name, data in archive.items():
destination = source / name
if destination.is_symlink() or not destination.parent.resolve().is_relative_to(source.resolve()):
raise RuntimeError("Unexpected stage symbolic link: " + name)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
project_append(source / "px_Collect.pro")
# 图片fallback单独指定PXHJ,不读取或改变固定采集目标来猜测原图目的地。
config = candidate_config((root / "config.ini").read_text(encoding="utf-8-sig"),
args.gateway_host, args.gateway_port, args.fallback_ip, args.fallback_port)
candidate = directory / "candidate-config.ini"
# 协商enabled=true只进入候选配置,此时当前程序仍读取原配置。
candidate.write_text(config, encoding="utf-8")
os.chmod(str(candidate), 0o600)
# 元数据只含文件哈希和路径,回退材料与候选config均由stage目录权限保护。
# 原来不存在的新文件以None记录,切换时不会把新增与覆盖混为一谈。
metadata = {"format": 1, "root": str(root), "stage": str(directory),
"packageSha256": digest(args.package), "previousPatchHashes": previous,
"preservedHashes": baseline, "previousProjectSha256": digest(root / "px_Collect.pro"),
"previousBinarySha256": digest(root / "bin/px_JKGL"),
"previousConfigSha256": digest(root / "config.ini"),
"candidateConfigSha256": digest(candidate),
"candidatePatchHashes": {name: digest(source / name) for name in PATCH_FILES},
"candidateProjectSha256": digest(source / "px_Collect.pro"), "built": False}
write_json(directory / "stage.json", metadata)
print(json.dumps({"stage": str(directory), "liveChanged": False,
"backup": str(backup), "candidateConfig": str(candidate)}, ensure_ascii=False))
def stage_record(args):
root = live_root(args)
# 构建与回退都必须绑定创建时的root/stage身份,不能复用别的工程记录。
if args.stage.is_symlink():
raise RuntimeError("Stage directory cannot be a symbolic link")
directory = args.stage.resolve(strict=True)
if not directory.is_relative_to((root / "runtime").resolve()) or directory == root / "runtime":
raise RuntimeError("Stage must stay inside the collector runtime directory")
# 不根据文件名猜测stage所属,记录内的绝对路径必须和当前解析结果相同。
record = json.loads((directory / "stage.json").read_text(encoding="utf-8"))
if record.get("root") != str(root) or record.get("stage") != str(directory):
raise RuntimeError("Stage record does not match its target root")
# 返回规范化路径供build/verify/rollback共用,防止作用范围逐步漂移。
return root, directory, record
def verify(args):
root, directory, record = stage_record(args)
# 任一在用源码/程序/配置从stage以后改变,就停止,不能覆盖更新后的版本。
# 新文件原本不存在也要记录:出现外部新增版本时同样不能覆盖。
expected = dict(record["preservedHashes"])
expected.update(record["previousPatchHashes"])
expected.update({"px_Collect.pro": record["previousProjectSha256"],
"bin/px_JKGL": record["previousBinarySha256"], "config.ini": record["previousConfigSha256"]})
# 不打印任何差异内容,仅报告路径;例如config变化不能泄漏其中的凭证。
changed = [name for name, value in expected.items()
if (digest(root / name) if (root / name).is_file() else None) != value]
if changed:
raise RuntimeError("Live files changed since staging: " + ", ".join(changed))
print(json.dumps({"stage": str(directory), "liveUnchanged": True, "built": record["built"]}))
def run_logged(command, cwd, log):
# 不通过shell拼接命令,参数中的路径作为独立argv传递。
# check=True保证失败停止后续步骤,不把半编译候选标成built。
# 构建输出只写stage日志,失败保留全部证据;不占用原run.log预算。
with log.open("wb") as stream:
subprocess.run(command, cwd=str(cwd), stdout=stream, stderr=subprocess.STDOUT, check=True)
def build(args):
# 本机Windows测试不能代替全ARM工程,本模式明确限定aarch64环境。
# 当前代码不提供任何去掉相机/GStreamer/日志依赖来勉强构建的降级分支。
root, directory, record = stage_record(args)
if platform.machine() not in {"aarch64", "arm64"}:
raise RuntimeError("Full application build must run on the ARM64 target environment")
# build前再次核对生产文件没被其他部署修改,避免基于陈旧源生成候选。
verify(args)
source = directory / "source"
# 源码保留核对发生在任何qmake/make之前,避免事后才发现构建了旧main。
for name in PRESERVED:
if name in record["preservedHashes"] and digest(source / name) != record["preservedHashes"][name]:
raise RuntimeError("Preserved staged source changed: " + name)
build_dir = directory / "build"
build_dir.mkdir(exist_ok=True)
# 空本地stash防止继承祖先缓存;不修改板上已用工程的.qmake.stash。
(build_dir / ".qmake.stash").touch()
run_logged(["qmake", str(source / "px_Collect.pro"), "-after", "INCLUDEPATH+=" + str(source)],
build_dir, directory / "qmake.log")
# 已知板时钟可能早于Qt依赖,阻止Makefile自身反复重生成,但正常编译依赖仍保留。
run_logged(["make", "-o", "Makefile", "-j" + str(args.jobs)], build_dir, directory / "build.log")
# qmake的PWD是隔离source,DESTDIR因此只写stage/source/bin,生产bin不变。
executable = source / "bin/px_JKGL"
if not executable.is_file() or not os.access(str(executable), os.X_OK):
raise RuntimeError("Full build did not produce an executable")
# ldd只检查候选库可解析;LD_LIBRARY_PATH沿用当前启动脚本的SDK/hiredis路径。
environment = dict(os.environ)
environment["LD_LIBRARY_PATH"] = str(root / "3rdparty/libs") + ":/usr/local/lib/pt_libs:" + environment.get("LD_LIBRARY_PATH", "")
# 子进程环境不输出到日志,只保留ldd给出的库路径和解析结果。
linked = subprocess.run(["ldd", str(executable)], env=environment, capture_output=True, text=True, check=True)
(directory / "ldd.log").write_text(linked.stdout, encoding="utf-8")
# 可执行文件存在不代表能启动,未解析的相机/Qt/hiredis库必须在切换前处理。
if "not found" in linked.stdout:
raise RuntimeError("Candidate has unresolved shared libraries; inspect staged ldd.log")
# 原日志测试证明预算与入口不退化;协商测试证明回程、租约和原图协议。
for name, project, executable_name in (
("logging", "native_logging_check", "native_logging_check"),
("image_route", "native_image_route_check", "native_image_route_check"),
):
test_dir = directory / ("test-" + name)
test_dir.mkdir(exist_ok=True)
(test_dir / ".qmake.stash").touch()
run_logged(["qmake", str(source / "tests" / name / (project + ".pro"))],
test_dir, directory / (name + "-qmake.log"))
# 测试也遵守目标板时钟限制,不改系统时间来规避工具链问题。
run_logged(["make", "-o", "Makefile", "-j" + str(args.jobs)],
test_dir, directory / (name + "-build.log"))
# 只运行隔离QtTest,不启动候选主程序,所以不会抢6000/9200或产生真实数据。
run_logged([str(test_dir / executable_name), "-o", str(directory / (name + "-tests.txt")) + ",txt"],
test_dir, directory / (name + "-run.log"))
# 只有完整程序、动态库检查和两套隔离测试都成功才记录built。
# 准备原图与迁移的业务验收仍需root通过真实PXHJ执行,不能由此标为完成。
record.update({"built": True, "candidateBinary": str(executable),
"candidateBinarySha256": digest(executable)})
# built标记仅描述候选准备,实际部署和业务验收还未由本工具执行。
write_json(directory / "stage.json", record)
print(json.dumps({"stage": str(directory), "buildPassed": True, "liveChanged": False,
"candidateBinary": str(executable), "candidateBinarySha256": digest(executable)}))
def arguments():
# 默认inspect,防止操作者漏写模式便执行任何准备或构建写入。
# 所有stage参数都显式给出,不沿用历史部署的IP猜测当前网卡状态。
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("package", "inspect", "stage", "build", "verify"), nargs="?", default="inspect")
parser.add_argument("--source", type=Path, default=Path("E:/Codex/qrs/SY_26_PX_Collect"))
parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect"))
parser.add_argument("--package", type=Path)
parser.add_argument("--stage", type=Path)
parser.add_argument("--gateway-host")
parser.add_argument("--gateway-port", type=int, default=19002)
parser.add_argument("--fallback-ip")
parser.add_argument("--fallback-port", type=int, default=6020)
parser.add_argument("--jobs", type=int, default=4)
# mode本身就是明确操作入口;本脚本没有activate或自动启动功能。
args = parser.parse_args()
if args.mode in {"package", "stage"} and args.package is None:
parser.error("--package is required")
if args.mode in {"build", "verify"} and args.stage is None:
parser.error("--stage is required")
if not 1 <= args.jobs <= 16 or any(not 1 <= port <= 65535 for port in (args.gateway_port, args.fallback_port)):
parser.error("Invalid build jobs or UDP port")
if args.mode == "stage":
# 禁止DNS猜测和广播,gateway及fallback必须由root明确给出当前部署IP。
for name, value in (("gateway-host", args.gateway_host), ("fallback-ip", args.fallback_ip)):
try:
address = ipaddress.IPv4Address(value)
if address.is_unspecified or address.is_multicast or int(address) == 0xffffffff:
raise ValueError()
except (ValueError, TypeError):
parser.error("--" + name + " must be an explicit unicast IPv4 address")
return args
if __name__ == "__main__":
options = arguments()
{"package": package, "inspect": inspect, "stage": stage, "build": build, "verify": verify}[options.mode](options)
+40
View File
@@ -0,0 +1,40 @@
[CmdletBinding()]
param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot), [string]$JavaHome)
. (Join-Path $PSScriptRoot 'Common.ps1')
$root = [System.IO.Path]::GetFullPath($InstallRoot)
$processFile = Join-Path $root 'service-process.json'
$deployment = Get-Content -LiteralPath (Join-Path $root 'deployment.json') -Raw | ConvertFrom-Json
if (Test-Path -LiteralPath $processFile) {
$record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json
if (Get-XtrhOwnedProcess -InstallRoot $root -Record $record) {
Write-Output '本部署实例已在运行。'
return
}
}
$java = Get-XtrhJava -JavaHome $JavaHome
$jar = Join-Path $root 'xtrh-services.jar'
if (-not (Test-Path -LiteralPath $jar)) { throw '未找到部署 JAR。' }
$arguments = @('-Djava.awt.headless=true', '-jar', ('"' + $jar + '"'), '--spring.config.additional-location=file:./config/service.local.properties')
$process = Start-Process -FilePath $java -ArgumentList $arguments -WorkingDirectory $root -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $root 'logs/stdout.log') -RedirectStandardError (Join-Path $root 'logs/stderr.log')
$record = @{pid = $process.Id; startTicks = $process.StartTime.ToUniversalTime().Ticks.ToString(); javaPath = $java; jarPath = $jar}
Write-XtrhText -Path $processFile -Content ($record | ConvertTo-Json)
$ready = $false
$deadline = [DateTime]::UtcNow.AddSeconds(40)
while ([DateTime]::UtcNow -lt $deadline) {
$process.Refresh()
if ($process.HasExited) { break }
try {
$health = Invoke-RestMethod -Uri ('http://127.0.0.1:' + $deployment.port + '/health') -TimeoutSec 1
$identityFile = Join-Path $root 'data/identity.txt'
if ((Test-Path -LiteralPath $identityFile) -and $health.service -eq 'xtrh-services' -and $health.role -eq $deployment.role -and
$health.serviceId -eq (Get-Content -LiteralPath $identityFile -Raw).Trim()) { $ready = $true; break }
} catch { }
Start-Sleep -Milliseconds 500
}
if (-not $ready) {
$owned = Get-XtrhOwnedProcess -InstallRoot $root -Record $record
if ($owned) { Stop-Process -Id $owned.Id -Force }
throw ('服务未通过启动校验;请查看 ' + (Join-Path $root 'logs'))
}
Write-Output ('服务已启动并通过身份校验:' + $deployment.role + ',端口 ' + $deployment.port)
+11
View File
@@ -0,0 +1,11 @@
[CmdletBinding()]
param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot))
. (Join-Path $PSScriptRoot 'Common.ps1')
$root = [System.IO.Path]::GetFullPath($InstallRoot)
$processFile = Join-Path $root 'service-process.json'
if (-not (Test-Path -LiteralPath $processFile)) { Write-Output '此部署目录没有启动记录。'; return }
$record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json
$process = Get-XtrhOwnedProcess -InstallRoot $root -Record $record
if ($process) { Stop-Process -Id $process.Id -Force }
Remove-Item -LiteralPath $processFile -Force
Write-Output '此部署实例已停止;数据、身份和配置均保留。'
+44
View File
@@ -0,0 +1,44 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$SourceEndpointId,
[Parameter(Mandatory)][string]$FromNodeId,
[Parameter(Mandatory)][string]$ToNodeId,
[Parameter(Mandatory)][string]$ReadyMarker,
[Parameter(Mandatory)][string]$StartMarker,
[Parameter(Mandatory)][string]$OutputDirectory,
[string]$CoordinatorUrl = 'http://127.0.0.1:18100',
[string]$CredentialFile = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/cluster.local.properties'),
[ValidateRange(1,300)][int]$WaitSeconds = 90,
[ValidateRange(1,3600)][int]$DurationSeconds = 25
)
$ErrorActionPreference = 'Stop'
$readyPath = [IO.Path]::GetFullPath($ReadyMarker)
$startPath = [IO.Path]::GetFullPath($StartMarker)
[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($readyPath))
$readyAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$requestId = [Guid]::NewGuid().ToString()
# 只保存随机门闩与公开业务标识,文件中没有集群密钥、浏览器会话或图片访问地址。
$ready = [ordered]@{ requestId = $requestId; endpointId = $SourceEndpointId; fromNodeId = $FromNodeId
toNodeId = $ToNodeId; readyAtMs = $readyAt }
[IO.File]::WriteAllText($readyPath, ($ready | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false))
Write-Output '迁移脚本已等待前端采样启动信号,尚未改容量。'
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds)
$started = $false
while ([DateTimeOffset]::UtcNow -lt $deadline) {
if (Test-Path -LiteralPath $startPath) {
try {
$marker = [IO.File]::ReadAllText($startPath) | ConvertFrom-Json
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
# 匹配本次随机门闩,并要求浏览器采样已开始、时间新鲜,旧文件不能触发迁移。
if ($marker.requestId -eq $requestId -and $marker.endpointId -eq $SourceEndpointId -and
[long]$marker.startedAtMs -ge $readyAt -and [long]$marker.startedAtMs -le $now + 1000 -and
$now - [long]$marker.startedAtMs -le 5000) { $started = $true; break }
} catch { }
}
Start-Sleep -Milliseconds 100
}
if (-not $started) { throw '前端采样启动信号等待超时,未修改容量。' }
Write-Output '前端采样启动信号已匹配,开始已授权的原图迁移。'
& (Join-Path $PSScriptRoot 'Observe-OriginalImage.ps1') -CoordinatorUrl $CoordinatorUrl -CredentialFile $CredentialFile `
-SourceEndpointId $SourceEndpointId -FromNodeId $FromNodeId -ToNodeId $ToNodeId -Migrate -QuietFrames `
-DurationSeconds $DurationSeconds -PollMilliseconds 200 -OutputDirectory $OutputDirectory
@@ -0,0 +1,199 @@
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import tarfile
import tempfile
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
path = Path(__file__).resolve().parents[1] / "Stage-EmbeddedImageRoute.py"
spec = importlib.util.spec_from_file_location("route_stage", path)
stage = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stage)
class ImageRouteStagingTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.work = Path(self.temporary.name)
self.root = self.work / "board"
self.root.mkdir()
for relative in stage.PRESERVED:
target = self.root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("board latest source: CollectorLogRetention::install PlatformImageSender\n", encoding="utf-8")
(self.root / "px_Collect.pro").write_text("DEFINES += COLLECTOR_WITH_GST\nLIBS += -L/board/vendor -lhiredis\n", encoding="utf-8")
(self.root / "bin").mkdir()
(self.root / "bin/px_JKGL").write_bytes(b"original-test-binary")
(self.root / "config.ini").write_text("; original comment\n[fixed_json]\nudp_host=198.51.100.1\nudp_port=19001\n"
"[heartbeat]\ninterval_ms=3000\n[image_upload]\nenabled=true\ninterval_ms=100\n", encoding="utf-8")
(self.root / "module_keys.json").write_text("not a real credential", encoding="utf-8")
launcher = self.root / "start-network-simulation.sh"
launcher.write_text("#!/bin/sh\n# current private board launcher\n", encoding="utf-8")
launcher.chmod(0o755)
self.package = self.work / "route.tar.gz"
content = {}
manifest = {"format": 1, "protocol": "COLLECTOR_IMAGE_ROUTE", "files": []}
import hashlib
for name in stage.PATCH_FILES + stage.TEST_FILES:
data = b"isolated test source\n"
content[name] = data
manifest["files"].append({"path": name, "sha256": hashlib.sha256(data).hexdigest()})
content["image-route-manifest.json"] = json.dumps(manifest).encode()
with tarfile.open(str(self.package), "w:gz") as archive:
for name, data in content.items():
item = tarfile.TarInfo(name); item.size = len(data)
archive.addfile(item, io.BytesIO(data))
self.args = SimpleNamespace(root=self.root, package=self.package, gateway_host="192.0.2.10",
gateway_port=19002, fallback_ip="192.0.2.11", fallback_port=6020)
def create_stage(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
stage.stage(self.args)
return Path(json.loads(output.getvalue())["stage"])
def test_stage_keeps_live_sources_binary_config_and_keys_untouched(self):
original = (self.root / "config.ini").read_bytes()
directory = self.create_stage()
self.assertEqual(original, (self.root / "config.ini").read_bytes())
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
self.assertFalse((self.root / stage.PATCH_FILES[0]).exists())
self.assertFalse((directory / "source/module_keys.json").exists())
self.assertFalse((directory / "source/config.ini").exists())
self.assertEqual((self.root / "main.cpp").read_bytes(), (directory / "source/main.cpp").read_bytes())
text = (directory / "source/px_Collect.pro").read_text(encoding="utf-8")
self.assertIn("LIBS += -L/board/vendor -lhiredis", text)
self.assertIn("SOURCES += util/PlatformImageRouteClient.cpp", text)
def test_candidate_config_sets_independent_fallback_and_preserves_other_settings(self):
directory = self.create_stage()
text = (directory / "candidate-config.ini").read_text(encoding="utf-8")
self.assertIn("; original comment", text)
self.assertIn("[fixed_json]\nudp_host=198.51.100.1\nudp_port=19001", text)
self.assertIn("[heartbeat]\ninterval_ms=3000", text)
self.assertIn("interval_ms=100", text)
self.assertIn("udp_host=192.0.2.11", text)
self.assertIn("gateway_host=192.0.2.10", text)
def test_verify_rejects_source_changed_after_stage(self):
directory = self.create_stage()
args = SimpleNamespace(root=self.root, stage=directory)
with contextlib.redirect_stdout(io.StringIO()):
stage.verify(args)
(self.root / "main.cpp").write_text("new board modification", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "main.cpp"):
stage.verify(args)
def test_archive_path_traversal_is_rejected_without_writing(self):
with tarfile.open(str(self.package), "w:gz") as archive:
item = tarfile.TarInfo("../outside.cpp"); item.size = 1
archive.addfile(item, io.BytesIO(b"x"))
with self.assertRaisesRegex(RuntimeError, "archive contents"):
stage.payload(self.args)
self.assertFalse((self.work / "outside.cpp").exists())
def test_missing_native_log_feature_cannot_be_silently_downgraded(self):
(self.root / "main.cpp").write_text("old Qt application without native log", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "Required existing feature missing: main.cpp"):
self.create_stage()
def test_bad_config_error_does_not_echo_original_line(self):
with self.assertRaisesRegex(RuntimeError, "unsupported or duplicate entries") as error:
stage.candidate_config("[image_upload]\nenabled=true\nCONFIDENTIAL-TEST-LINE", "192.0.2.10", 19002, "192.0.2.11", 6020)
self.assertNotIn("CONFIDENTIAL", str(error.exception))
def test_rollback_preview_validates_current_ownership_without_changing_live_files(self):
directory = self.create_stage()
metadata_file = directory / "stage.json"
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
metadata.update(built=True, candidateBinarySha256="synthetic-unused-candidate-hash")
metadata_file.write_text(json.dumps(metadata), encoding="utf-8")
command = [sys.executable, str(path.with_name("Rollback-EmbeddedImageRoute.py")),
"--root", str(self.root), "--stage", str(directory)]
original = (self.root / "config.ini").read_bytes()
completed = subprocess.run(command, capture_output=True, text=True, check=True)
self.assertTrue(json.loads(completed.stdout)["rollbackReady"])
self.assertEqual(original, (self.root / "config.ini").read_bytes())
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
(self.root / "config.ini").write_text("unrelated newer configuration", encoding="utf-8")
completed = subprocess.run(command, capture_output=True, text=True)
self.assertNotEqual(completed.returncode, 0)
self.assertIn("Unrelated live update detected: config.ini", completed.stderr)
def built_stage(self):
directory = self.create_stage()
binary = directory / "source/bin/px_JKGL"
binary.parent.mkdir(); binary.write_bytes(b"isolated-candidate-binary"); binary.chmod(0o755)
metadata_file = directory / "stage.json"
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
metadata.update(built=True, candidateBinarySha256=stage.digest(binary), candidateBinary=str(binary))
metadata_file.write_text(json.dumps(metadata), encoding="utf-8")
(directory / "logging-tests.txt").write_text("Totals: 9 passed, 0 failed, 0 skipped", encoding="utf-8")
(directory / "image_route-tests.txt").write_text("SKIP: javaAndCppWireCodecInteroperateWithRealEncryptedFrames\n"
"Totals: 8 passed, 0 failed, 1 skipped", encoding="utf-8")
return directory
def load_script(self, name):
spec = importlib.util.spec_from_file_location(name, path.with_name(name + ".py"))
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module
def test_activation_preview_and_success_replace_only_owned_files(self):
activate = self.load_script("Activate-EmbeddedImageRoute")
directory = self.built_stage()
args = SimpleNamespace(root=self.root, stage=directory, apply=False, health_timeout=15)
with contextlib.redirect_stdout(io.StringIO()):
activate.activate(args)
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
args.apply = True
health = {"collectorPids": [4321], "nativeLogging": {"owner": "px_JKGL", "limitBytes": 1024 ** 3,
"withinLimit": True, "failedWrites": 0}}
with patch.object(activate, "stop_collector"), patch.object(activate, "launch") as launch, \
patch.object(activate, "healthy", return_value=health), contextlib.redirect_stdout(io.StringIO()):
activate.activate(args)
launch.assert_called_once()
self.assertEqual(b"isolated-candidate-binary", (self.root / "bin/px_JKGL").read_bytes())
self.assertEqual((directory / "candidate-config.ini").read_bytes(), (self.root / "config.ini").read_bytes())
self.assertEqual((directory / "source/main.cpp").read_bytes(), (self.root / "main.cpp").read_bytes())
self.assertTrue(json.loads((directory / "activation-result.json").read_text())["activated"])
def test_failed_activation_invokes_real_rollback_logic_with_simulated_processes(self):
activate = self.load_script("Activate-EmbeddedImageRoute")
rollback = self.load_script("Rollback-EmbeddedImageRoute")
directory = self.built_stage()
original_config = (self.root / "config.ini").read_bytes()
args = SimpleNamespace(root=self.root, stage=directory, apply=True, health_timeout=15)
launched = {"old": False}
def simulated_run(command, **kwargs):
if command[0] == sys.executable:
with patch.object(sys, "argv", command[1:]):
rollback.main()
else:
self.assertEqual(command, [str(self.root / "start-network-simulation.sh")])
launched["old"] = True
return subprocess.CompletedProcess(command, 0)
with patch.object(activate, "stop_collector"), patch.object(activate, "launch", side_effect=RuntimeError("simulated launch failed")), \
patch.object(activate.subprocess, "run", side_effect=simulated_run), \
patch.object(rollback.helper, "process_ids", side_effect=lambda root: [4321] if launched["old"] else []), \
patch.object(rollback.time, "sleep"), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(RuntimeError, "rollbackSucceeded=True"):
activate.activate(args)
self.assertTrue(launched["old"])
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
self.assertEqual(original_config, (self.root / "config.ini").read_bytes())
self.assertFalse((self.root / "util/PlatformImageRouteClient.cpp").exists())
self.assertTrue(json.loads((directory / "activation-error.json").read_text())["rollbackSucceeded"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,60 @@
# 嵌入式图片协商候选部署
该工具准备可审阅的候选版本,默认仅检查。创建stage和构建不会停止采集程序、修改在用配置或替换在用可执行文件;实际SSH、切换、业务联调由总任务执行。
## 已核对的边界
历史部署及本次根任务SSH只读确认:采集板 `root@192.168.7.165`、ARM64 RK3588,工程 `/home/Tronlong/SY26-PX-Collect`,启动入口 `start.sh`,程序 `bin/px_JKGL`。本机平台地址 `192.168.7.222`;本次网卡路由需SSH显式 `-b 192.168.7.222`。主机密钥使用现有 `E:/pxhj/runtime/board-known-hosts` 严格核验,不自动忽略未知主机密钥。
先由root核对板时钟,当前发现其严重落后。采集协议要求时间差不超过60秒,不能只修复make循环而保留错误时钟。SSH接入和校时由root负责,此工具不配置网络、系统时间或服务。
本次包只含6份图片协商相关C++源码和两套隔离QtTest。板上当前源码及SDK作为构建基线;`main.cpp`、内置1GiB日志、RTP收流、串口桥、现有启动脚本和其他源码均保留。`px_Collect.pro`只追加缺少的编译单元。旧 `runtime/embedded-log-retention/deploy.py` 会修改日志服务及旧启动重定向,不能直接用于本次。
## 生成和传输
在Windows运行:
```powershell
python E:\Codex\xtrh\scripts\Stage-EmbeddedImageRoute.py package --source E:\Codex\qrs\SY_26_PX_Collect --package E:\Codex\xtrh\runtime\board-image-route\collector-image-route.tar.gz
```
同名包不会覆盖。命令输出文件数和SHA256,包不含INI、设备密钥、运行日志或旧程序。root通过已确认SSH连接上传该包以及两个 `.py` 工具;不在命令或日志中放密码/密钥。
## 板端stage和构建
在已确认的板端目录执行下列命令,包路径按实际上传位置填入:
```sh
python3 Stage-EmbeddedImageRoute.py inspect
python3 Stage-EmbeddedImageRoute.py stage --package /path/collector-image-route.tar.gz --gateway-host 192.168.7.222 --gateway-port 19002 --fallback-ip 192.168.7.222 --fallback-port 6020
python3 Stage-EmbeddedImageRoute.py build --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
python3 Stage-EmbeddedImageRoute.py verify --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
```
`stage`输出真实候选路径。stage目录权限700,配置及其备份权限600。构建必须在ARM64目标环境,完整Qt应用、GStreamer、相机SDK、OpenSSL和hiredis依赖全部保留;不存在去掉依赖勉强通过的分支。`make -o Makefile`仅防止板时钟引起Makefile重复生成,不省略正常编译。新目录使用自己的qmake缓存。
完整程序构建成功后检查动态库,并执行内置日志和图片协商隔离QtTest。只有全部成功才记为 `built=true`。候选主程序不会在此阶段启动,不抢真实6000/9200端口。跨语言加解密已在Windows独立验证;ARM默认缺少Java互操作夹具时该可选测试会标记跳过,其他业务断言仍执行。
候选程序在 `<stage>/source/bin/px_JKGL`,候选配置在 `<stage>/candidate-config.ini`,完整回退材料在 `<stage>/backup`,日志和哈希在该stage内。`verify`若发现现网源码、程序或配置在stage后变化会停止,root应重新核对,不能覆盖新修改。
## 切换与回退
root先核对真实PXHJ网关路由已经授权该采集端,再切换。候选配置只新增/启用地址协商以及独立PXHJ原图fallback IP;不修改fixed_json、heartbeat、串口/RTP或密钥文件。图片目的地址不能从fixed_json网关地址推断。保持原图片帧率、质量和尺寸。
实际切换前,运行 `Activate-EmbeddedImageRoute.py` 默认预览。它核验live未变、候选bin/config/源码/pro哈希及两套QtTest结果;显式 `--apply` 才停止本工程采集进程、原子安装候选并调用当前 `start-network-simulation.sh`。原串口桥和PID管理方式保留,禁止恢复旧的无限增长 `run.log` 重定向。启动后核对新写入且连续稳定的native日志状态(owner、1GiB预算、failedWrites=0、withinLimit=true)。失败自动调用该stage回退。准备原图可见、来源身份不变、迁移首帧与租约回退由真实PXHJ继续验收。
```sh
python3 Activate-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
python3 Activate-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> --apply
```
回退先只检查,所有路径和哈希通过后由root显式执行:
```sh
python3 Rollback-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
python3 Rollback-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> --apply
```
回退仅恢复本stage拥有的6份源码、pro、程序和config;默认无写操作。检测到其他部署后的更新、备份哈希改变或未知进程守护重启时停止。默认重用当前 `start-network-simulation.sh`,串口桥与collector.pid由原启动器管理;显式 `--launcher start.sh` 才选择直接启动。不会修改网络、systemd、设备密钥、原始采集数据或试验记录。
此文档和候选包属于部署准备;实际ARM构建、切换及准备/迁移验收需要分别记录真实结果,不能用本机测试替代。