初始化系统融合软件代码仓库
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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 '运行中的后端不会自动加载新源码。'
|
||||
@@ -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 静态资源目录。'
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 + '。后端重启后加载。')
|
||||
@@ -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 = ''
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
@@ -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 '部分原容量未恢复,请按保存的迁移计划处理。' }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 '此部署实例已停止;数据、身份和配置均保留。'
|
||||
@@ -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构建、切换及准备/迁移验收需要分别记录真实结果,不能用本机测试替代。
|
||||
Reference in New Issue
Block a user