126 lines
7.2 KiB
Python
126 lines
7.2 KiB
Python
#!/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()
|