Files
xtrhrj/scripts/Stage-EmbeddedImageRoute.py
T

444 lines
25 KiB
Python
Raw Normal View History

2026-10-20 08:34:12 +08:00
#!/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)