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