#!/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()