初始化系统融合软件代码仓库

This commit is contained in:
2026-10-20 08:34:12 +08:00
commit 587ae6f088
96 changed files with 10230 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
#!/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()