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