Files

1091 lines
46 KiB
Python

#!/usr/bin/env python3
"""嵌入式应用模块部署预检与受保护执行工具。
默认只执行身份核验和设备端只读预检。只有同时指定非 preflight 动作与
--execute 时,才会挂载 NFS、停止进程、复制文件或恢复文件。
"""
from __future__ import annotations
import argparse
import getpass
import html
import json
import os
import re
import shlex
import shutil
import socket
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
INFO_COMMAND = 30101
CONFIG_PORT = 8942
CRITICAL_MODULES = {
"EmxCoreServer",
"EmxMediaServer",
"EmxModulesServer",
"WatchDog",
}
CRITICAL_MODULE_KEYS = {name.casefold() for name in CRITICAL_MODULES}
class DebugError(RuntimeError):
"""可直接展示给用户的受控错误。"""
def configure_output() -> None:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
class EventLogger:
def __init__(self, log_dir: Path) -> None:
log_dir.mkdir(parents=True, exist_ok=True)
self.path = log_dir / "module-debug.jsonl"
def write(self, event: str, **fields: Any) -> None:
record = {
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"event": event,
**fields,
}
line = json.dumps(record, ensure_ascii=False)
with self.path.open("a", encoding="utf-8") as stream:
stream.write(line + "\n")
print(line)
def recv_exact(sock: socket.socket, length: int) -> bytes:
data = bytearray()
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
raise DebugError("设备在响应完成前关闭了连接")
data.extend(chunk)
return bytes(data)
def query_identity(host: str, timeout: float) -> Dict[str, Any]:
"""通过 EPTP/30101 查询设备身份并校验响应。"""
session = int(time.time_ns() % 2_000_000_000)
request = json.dumps(
{"cmd": INFO_COMMAND, "session": session}, separators=(",", ":")
).encode("utf-8")
with socket.create_connection((host, CONFIG_PORT), timeout=timeout) as sock:
sock.settimeout(timeout)
sock.sendall(b"EPTP" + struct.pack("!I", len(request)) + request)
header = recv_exact(sock, 8)
if header[:4] != b"EPTP":
raise DebugError("30101 响应同步头不是 EPTP")
size = struct.unpack("!I", header[4:])[0]
if size < 2 or size > 1024 * 1024:
raise DebugError("30101 响应长度异常:%d" % size)
response = json.loads(recv_exact(sock, size).decode("utf-8"))
if response.get("cmd") != INFO_COMMAND:
raise DebugError("30101 响应命令不匹配")
if response.get("session") != session:
raise DebugError("30101 响应 session 不匹配")
if response.get("code") != 0:
raise DebugError("30101 返回失败:%s" % response)
return response
def identity_models(response: Dict[str, Any]) -> List[str]:
data = response.get("data") or {}
result: List[str] = []
for key in ("inner", "outer", "hardware"):
value = (data.get(key) or {}).get("type")
if value:
result.append(str(value))
return result
def verify_identity(
response: Dict[str, Any], expected_sn: str, expected_model: str
) -> None:
data = response.get("data") or {}
actual_sn = str(data.get("sn") or "")
models = identity_models(response)
if actual_sn != expected_sn:
raise DebugError("设备 SN 不匹配:期望 %s,实际 %s" % (expected_sn, actual_sn))
if expected_model not in models:
raise DebugError(
"设备型号不匹配:期望 %s,实际 %s"
% (expected_model, ", ".join(models) or "<空>")
)
def broadcast_verify_identity(
expected_host: str, expected_sn: str, expected_model: str, timeout: float
) -> Dict[str, Any]:
"""调用同目录 V2 搜索脚本,从全部活动网卡重新广播并核对目标 IP。"""
discover_script = Path(__file__).with_name("v2_discover.py")
if not discover_script.is_file():
raise DebugError("找不到 V2 广播搜索脚本:%s" % discover_script)
command = [
sys.executable,
str(discover_script),
"--expected-sn",
expected_sn,
"--expected-model",
expected_model,
"--include-virtual",
"--timeout",
str(max(0.5, min(timeout, 30.0))),
]
result = subprocess.run(
command,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=max(10.0, min(timeout, 30.0) + 10.0),
check=False,
)
records: List[Dict[str, Any]] = []
for line in result.stdout.splitlines():
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(record, dict):
records.append(record)
summaries = [item for item in records if item.get("event") == "summary"]
if result.returncode != 0 or not summaries:
detail = result.stderr.strip() or result.stdout.strip() or "无搜索输出"
raise DebugError("V2 广播身份核验失败:%s" % detail)
summary = summaries[-1]
matches = summary.get("matches") or []
if not summary.get("identity_verified") or len(matches) != 1:
raise DebugError("V2 广播没有唯一匹配目标设备")
match = matches[0]
actual_host = str(match.get("source_ip") or "")
if actual_host != expected_host:
raise DebugError(
"广播发现的目标 IP 已变化:期望 %s,实际 %s" % (expected_host, actual_host)
)
return summary
class DeviceSession:
def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]:
raise NotImplementedError
def close(self) -> None:
pass
class SerialWebSession(DeviceSession):
"""复用已有串口网页控制台,不直接打开 COM 口。"""
def __init__(self, base_url: str, expected_port: str, timeout: float) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
page = self._request("/").decode("utf-8", errors="replace")
match = re.search(r'data-token="([^"]+)"', page)
if not match:
raise DebugError("串口网页控制台没有返回会话令牌")
self.token = html.unescape(match.group(1))
# 只有用户的网页维护页面租约。自动化读状态/日志不能冒充网页保活,
# 否则用户关闭最后页面后,本工具轮询会阻止串口按约定释放。
status = json.loads(self._request("/api/status"))
if not status.get("isOpen"):
raise DebugError("串口网页控制台存在,但串口没有打开")
actual_port = str(status.get("port") or "")
if actual_port.upper() != expected_port.upper():
raise DebugError(
"串口控制台端口不匹配:期望 %s,实际 %s"
% (expected_port, actual_port)
)
def _request(
self, path: str, data: Optional[bytes] = None, token: bool = False
) -> bytes:
headers: Dict[str, str] = {}
if data is not None:
headers["Content-Type"] = "text/plain; charset=utf-8"
if token:
headers["X-Serial-Token"] = self.token
request = urllib.request.Request(
self.base_url + path,
data=data,
headers=headers,
method="POST" if data is not None else "GET",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return response.read()
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise DebugError("串口网页控制台请求失败:%s" % exc) from exc
def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]:
marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex
wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker)
start_log = self._request("/api/log").decode("utf-8", errors="replace")
self._request(
"/api/send?ending=CR",
data=wrapped.encode("utf-8"),
token=True,
)
deadline = time.monotonic() + timeout
latest = start_log
pattern = re.compile(re.escape(marker) + r"=(-?\d+)")
while time.monotonic() < deadline:
time.sleep(0.2)
latest = self._request("/api/log").decode("utf-8", errors="replace")
matches = pattern.findall(latest)
if matches:
offset = min(len(start_log), len(latest))
return int(matches[-1]), latest[offset:]
raise DebugError(
"等待串口命令结束标记超时;设备端命令可能仍在执行,状态未知,禁止直接重试;"
"请读取事务状态文件并核对进程和目标哈希。不会静默切换 Telnet"
)
class TelnetSession(DeviceSession):
"""只实现登录和 Shell 命令所需的最小 Telnet 协议。"""
IAC = 255
DONT = 254
DO = 253
WONT = 252
WILL = 251
SB = 250
SE = 240
def __init__(
self, host: str, port: int, username: str, password: str, timeout: float
) -> None:
self.sock = socket.create_connection((host, port), timeout=timeout)
self.sock.settimeout(0.5)
self.timeout = timeout
self.pending = b""
self._read_until((b"login:",), timeout)
self._send_line(username)
self._read_until((b"Password:",), timeout)
self._send_line(password)
marker = "__CODEX_LOGIN_%s__" % uuid.uuid4().hex
self._send_line("printf '\\n%s\\n'" % marker)
try:
token = marker.encode("ascii")
self._read_until((b"\n" + token + b"\r\n", b"\n" + token + b"\n"), timeout)
except DebugError as exc:
raise DebugError("Telnet 登录失败或未进入 Shell;停止尝试凭据") from exc
def _strip_telnet(self, data: bytes) -> bytes:
data = self.pending + data
self.pending = b""
output = bytearray()
index = 0
while index < len(data):
if data[index] != self.IAC:
output.append(data[index])
index += 1
continue
if index + 1 >= len(data):
self.pending = data[index:]
break
command = data[index + 1]
if command == self.IAC:
output.append(self.IAC)
index += 2
elif command in (self.DO, self.DONT, self.WILL, self.WONT):
if index + 2 >= len(data):
self.pending = data[index:]
break
option = data[index + 2]
reply = self.WONT if command in (self.DO, self.DONT) else self.DONT
self.sock.sendall(bytes((self.IAC, reply, option)))
index += 3
elif command == self.SB:
end = data.find(bytes((self.IAC, self.SE)), index + 2)
if end < 0:
self.pending = data[index:]
break
index = end + 2
else:
index += 2
return bytes(output)
def _read_until(self, tokens: Sequence[bytes], timeout: float) -> bytes:
deadline = time.monotonic() + timeout
buffer = bytearray()
while time.monotonic() < deadline:
try:
chunk = self.sock.recv(4096)
except socket.timeout:
continue
if not chunk:
raise DebugError("Telnet 连接提前关闭")
buffer.extend(self._strip_telnet(chunk))
if any(token in buffer for token in tokens):
return bytes(buffer)
raise DebugError("等待 Telnet 响应超时")
def _send_line(self, text: str) -> None:
self.sock.sendall(text.encode("utf-8") + b"\r\n")
def _read_until_regex(self, pattern: re.Pattern[bytes], timeout: float) -> bytes:
deadline = time.monotonic() + timeout
buffer = bytearray()
while time.monotonic() < deadline:
try:
chunk = self.sock.recv(4096)
except socket.timeout:
continue
if not chunk:
raise DebugError("Telnet 连接提前关闭")
buffer.extend(self._strip_telnet(chunk))
if pattern.search(buffer):
return bytes(buffer)
raise DebugError(
"等待 Telnet 命令结束标记超时;设备端命令可能仍在执行,状态未知,"
"禁止直接重试;请重新核对身份后读取事务状态文件、进程和目标哈希"
)
def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]:
marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex
wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker)
self._send_line(wrapped)
byte_pattern = re.compile(re.escape(marker).encode("ascii") + rb"=-?\d+")
data = self._read_until_regex(byte_pattern, timeout)
text = data.decode("utf-8", errors="replace")
matches = re.findall(re.escape(marker) + r"=(-?\d+)", text)
if not matches:
raise DebugError("Telnet 命令没有返回有效结束标记")
return int(matches[-1]), text
def close(self) -> None:
try:
self._send_line("exit")
except OSError:
pass
self.sock.close()
def remote_file(ssh_host: str, remote_path: str, timeout: float) -> str:
if not remote_path.startswith("/") or "\n" in remote_path or "\r" in remote_path:
raise DebugError("远程工程路径必须是安全的绝对路径")
command = [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=%d" % max(1, int(timeout)),
ssh_host,
"cat " + shlex.quote(remote_path),
]
result = subprocess.run(
command,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout + 5,
check=False,
)
if result.returncode != 0:
raise DebugError("读取远程文件失败:%s" % result.stderr.strip())
return result.stdout
def load_telnet_credentials(
ssh_host: str, project_path: str, username: str, timeout: float
) -> Tuple[str, str]:
product_path = project_path.rstrip("/") + "/product.json"
product = json.loads(remote_file(ssh_host, product_path, timeout))
users = product.get("user") or []
for item in users:
if str(item.get("name") or "") == username:
password = str(item.get("password") or "")
if not password:
raise DebugError("product.json 中目标用户的密码为空")
return username, password
raise DebugError("product.json 中未找到 Telnet 用户 %s" % username)
def q(value: str) -> str:
return shlex.quote(value)
def validate_device_path(value: str, label: str) -> None:
if not value.startswith("/"):
raise DebugError("%s必须是设备绝对路径" % label)
if "\n" in value or "\r" in value or "\x00" in value:
raise DebugError("%s包含非法字符" % label)
def is_critical_target(module_name: str, target_path: str) -> bool:
names = {module_name.casefold(), Path(target_path).name.casefold()}
return bool(names & CRITICAL_MODULE_KEYS)
def preflight_command(args: argparse.Namespace) -> str:
source = q(args.source_path)
target = q(args.target_path)
mount_path = q(args.nfs_mount)
module = q(args.module_name)
return " ".join(
[
"printf 'PREFLIGHT_BEGIN\\n';",
"test -f %s || { echo SOURCE_MISSING; exit 41; };" % source,
"echo SOURCE_PATH=%s;" % source,
"ls -l %s;" % source,
"command -v file >/dev/null 2>&1 || { echo FILE_TOOL_MISSING; exit 44; };",
"file_output=$(file -L %s 2>/dev/null) || exit 44;" % source,
"echo FILE_RESULT=$file_output;",
"case \"$file_output\" in *ELF*) ;; *) echo SOURCE_NOT_ELF; exit 45;; esac;",
"device_arch=$(uname -m); echo DEVICE_ARCH=$device_arch;",
"case \"$device_arch:$file_output\" in arm*:*ARM*|aarch64:*ARM*|mips*:*MIPS*|x86_64:*x86-64*|i?86:*Intel*80386*) ;; *) echo ELF_ARCH_MISMATCH; exit 46;; esac;",
"if command -v readelf >/dev/null 2>&1; then",
"readelf -h %s 2>/dev/null || exit 47;" % source,
r"interp=$(readelf -l %s 2>/dev/null | sed -n 's/.*Requesting program interpreter: \(.*\)]/\1/p');"
% source,
"if [ -n \"$interp\" ]; then echo ELF_INTERPRETER=$interp; test -e \"$interp\" || { echo ELF_INTERPRETER_MISSING; exit 48; }; fi;",
"else %s fi;"
% (
"echo READELF_UNAVAILABLE_LIMITED_ABI_CHECK;"
if args.allow_limited_abi_check
else "echo READELF_REQUIRED; exit 49;"
),
"sha256sum %s;" % source,
"echo TARGET_PATH=%s;" % target,
"target_dir=$(dirname %s);" % target,
"test -d \"$target_dir\" || { echo TARGET_DIR_MISSING; exit 42; };",
"test -w \"$target_dir\" || { echo TARGET_DIR_NOT_WRITABLE; exit 43; };",
"grep -F %s /proc/mounts 2>/dev/null || echo NFS_MOUNT_NOT_PRESENT;"
% q(" %s " % args.nfs_mount),
"echo MODULE=%s;" % module,
"pidof %s 2>/dev/null || true;" % module,
"ps -o pid,ppid,args 2>/dev/null | grep '[d]aemon[.]sh' || true;",
"printf 'PREFLIGHT_END\\n'",
]
)
def nfs_mount_command(args: argparse.Namespace) -> Optional[str]:
if not args.mount_nfs:
return None
if not args.nfs_server or not args.nfs_export:
raise DebugError("--mount-nfs 需要同时提供 --nfs-server 和 --nfs-export")
source = "%s:%s" % (args.nfs_server, args.nfs_export)
return " ".join(
[
"expected_source=%s; mount_point=%s;" % (q(source), q(args.nfs_mount)),
"existing_source=$(awk -v m=\"$mount_point\" '$2 == m {print $1; exit}' /proc/mounts);",
"if [ -n \"$existing_source\" ] && [ \"$existing_source\" != \"$expected_source\" ]; then",
"echo NFS_SOURCE_MISMATCH:$existing_source:$expected_source; exit 31; fi;",
"if [ -z \"$existing_source\" ]; then mount -t nfs \"$expected_source\" \"$mount_point\" -o nolock; fi;",
"mounted_source=$(awk -v m=\"$mount_point\" '$2 == m {print $1; exit}' /proc/mounts);",
"test \"$mounted_source\" = \"$expected_source\" || { echo NFS_VERIFY_FAILED; exit 32; }",
]
)
def stop_process_fragment(
module_name: str, dxmain: bool, daemon_script_path: Optional[str] = None
) -> str:
fragments: List[str] = []
if dxmain:
fragments.extend(
[
"daemon_path=%s; shell_pid=$$; ps_snapshot=$(ps -o pid,ppid,args);"
% q(daemon_script_path or ""),
"daemon_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=\"$daemon_path\" '$1 != self && index($0, marker) > 0 {print $1}');",
"daemon_count=$(printf '%s\n' \"$daemon_pids\" | awk 'NF {n++} END {print n+0}');",
"test \"$daemon_count\" -le 1 || { echo MULTIPLE_DAEMON_MATCHES:$daemon_pids; exit 69; };",
"all_watchdog_pids=$(pidof SoftwareWatchdog 2>/dev/null || true);",
"watchdog_count=$(printf '%s\n' \"$all_watchdog_pids\" | awk '{print NF}');",
"test \"$watchdog_count\" -le 1 || { echo MULTIPLE_WATCHDOG_MATCHES:$all_watchdog_pids; exit 68; };",
"watchdog_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v parent=\"$daemon_pids\" '$2 == parent && /[S]oftwareWatchdog/ {print $1}');",
"test -z \"$all_watchdog_pids\" || test \"$watchdog_pids\" = \"$all_watchdog_pids\" || { echo WATCHDOG_PARENT_MISMATCH:$all_watchdog_pids:$daemon_pids; exit 65; };",
"app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name),
"test \"$daemon_count\" = 1 || { echo EXPECTED_DAEMON_NOT_RUNNING; exit 67; };",
"test -n \"$app_pids\" || { echo EXPECTED_PROCESS_NOT_RUNNING; exit 66; };",
"watchdog_was_running=0; test -z \"$watchdog_pids\" || watchdog_was_running=1;",
"app_was_running=1; process_recovery_needed=1;",
"guard_pids=\"$daemon_pids $watchdog_pids\";",
"if [ -n \"$(printf %s \"$guard_pids\" | tr -d ' ')\" ]; then kill -TERM $guard_pids 2>/dev/null || true; fi;",
"sleep 2;",
"ps_snapshot=$(ps -o pid,ppid,args);",
"daemon_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=\"$daemon_path\" '$1 != self && index($0, marker) > 0 {print $1}');",
"watchdog_pids=$(pidof SoftwareWatchdog 2>/dev/null || true);",
"guard_pids=\"$daemon_pids $watchdog_pids\";",
"if [ -n \"$(printf %s \"$guard_pids\" | tr -d ' ')\" ]; then kill -KILL $guard_pids 2>/dev/null || true; fi;",
"sleep 1;",
"ps_snapshot=$(ps -o pid,ppid,args);",
"daemon_pids=$(printf '%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=\"$daemon_path\" '$1 != self && index($0, marker) > 0 {print $1}');",
"watchdog_pids=$(pidof SoftwareWatchdog 2>/dev/null || true);",
"test -z \"$daemon_pids$watchdog_pids\" || { echo GUARD_STILL_RUNNING:$daemon_pids:$watchdog_pids; exit 71; };",
]
)
fragments.extend(
[
"app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name),
"app_was_running=0; test -z \"$app_pids\" || app_was_running=1;",
"test \"$app_was_running\" = 0 || process_recovery_needed=1;",
"if [ -n \"$app_pids\" ]; then kill -TERM $app_pids 2>/dev/null || true; fi;",
"sleep 2;",
"app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name),
"if [ -n \"$app_pids\" ]; then kill -KILL $app_pids 2>/dev/null || true; fi;",
"sleep 1;",
"app_pids=$(pidof %s 2>/dev/null || true);" % q(module_name),
"test -z \"$app_pids\" || { echo PROCESS_STILL_RUNNING:$app_pids; exit 72; };",
]
)
return " ".join(fragments)
def recovery_start_fragment(args: argparse.Namespace) -> str:
"""生成失败回滚后的进程恢复与存活核验命令。"""
if not args.stop_existing:
return "true"
if args.module_name.casefold() == "dxmain":
return " ".join(
[
"{ shell_pid=$$; ps_snapshot=$(ps -o pid,ppid,args);",
"daemon_now=$(printf '%%s\n' \"$ps_snapshot\" | awk -v self=\"$shell_pid\" -v marker=%s '$1 != self && index($0, marker) > 0 {print $1}');"
% q(args.daemon_script_path),
"if [ -z \"$daemon_now\" ]; then %s >/tmp/daemon-debug.log 2>&1 & fi;"
% args.daemon_start_command,
"sleep 2;",
"ps_snapshot=$(ps -o pid,ppid,args);",
"printf '%%s\n' \"$ps_snapshot\" | awk -v self=\"$$\" -v marker=%s '$1 != self && index($0, marker) > 0 {found=1} END {exit !found}' &&"
% q(args.daemon_script_path),
"pidof DxMain >/dev/null 2>&1 &&",
"{ test \"$watchdog_was_running\" = 0 || printf '%s\n' \"$ps_snapshot\" | awk -v parent=\"$daemon_now\" '$2 == parent && /[S]oftwareWatchdog/ {found=1} END {exit !found}'; }; }",
]
)
return " ".join(
[
"{ if [ \"$app_was_running\" = 1 ] && ! pidof %s >/dev/null 2>&1; then %s >%s 2>&1 & fi;"
% (
q(args.module_name),
args.rollback_start_command,
q("/tmp/%s-rollback.log" % args.module_name),
),
"sleep 2;",
"{ test \"$app_was_running\" = 0 || pidof %s >/dev/null 2>&1; }; }"
% q(args.module_name),
]
)
def rollback_function(restore_command: str, args: argparse.Namespace) -> str:
recovery = recovery_start_fragment(args)
return " ".join(
[
"rollback(){ rc=$?; trap - 0;",
"if [ \"$file_rollback_needed\" = 1 ] || [ \"$process_recovery_needed\" = 1 ]; then",
"printf 'ROLLBACK_STARTED\n' >\"$transaction\";",
"rollback_ok=1;",
"if [ \"$file_rollback_needed\" = 1 ]; then %s || rollback_ok=0; fi;"
% restore_command,
"sync;",
"if [ \"$process_recovery_needed\" = 1 ]; then %s || rollback_ok=0; fi;"
% recovery,
"if [ \"$rollback_ok\" != 1 ]; then printf 'ROLLBACK_FAILED\n' >\"$transaction\"; echo ROLLBACK_FAILED; exit 90; fi;",
"printf 'ROLLBACK_COMPLETED\n' >\"$transaction\";",
"echo ROLLBACK_COMPLETED;",
"fi; exit \"$rc\"; };",
"trap rollback 0;",
]
)
def deploy_command(args: argparse.Namespace) -> Tuple[str, str, str]:
stamp = time.strftime("%Y%m%d-%H%M%S")
unique = stamp + "-" + uuid.uuid4().hex[:8]
source = args.source_path
target = args.target_path
stage = target + ".codex-new-" + unique
backup = target + ".codex-backup-" + unique
transaction = "/tmp/codex-module-%s-deploy-%s.state" % (args.module_name, unique)
dxmain = args.module_name.casefold() == "dxmain"
parts = [
"src=%s; dst=%s; stage=%s; backup=%s; transaction=%s;"
% (q(source), q(target), q(stage), q(backup), q(transaction)),
"had_dst=0; file_rollback_needed=0; process_recovery_needed=0; app_was_running=0; watchdog_was_running=0;",
rollback_function(
"if [ \"$had_dst\" = 1 ]; then cp -p \"$backup\" \"$dst\"; else rm -f \"$dst\"; fi",
args,
),
"printf 'PREPARING\n' >\"$transaction\"; echo TRANSACTION_PATH=$transaction;",
"test -f \"$src\" || exit 41;",
"mode=755; if [ -e \"$dst\" ]; then mode=$(stat -c %a \"$dst\"); fi;",
"cp \"$src\" \"$stage\" || exit 51;",
"chmod \"$mode\" \"$stage\" || exit 52;",
"src_hash=$(sha256sum \"$src\" | awk '{print $1}');",
"stage_hash=$(sha256sum \"$stage\" | awk '{print $1}');",
"test \"$src_hash\" = \"$stage_hash\" || { echo HASH_MISMATCH; exit 53; };",
"if [ -e \"$dst\" ]; then had_dst=1; cp -p \"$dst\" \"$backup\" || exit 54; echo BACKUP_PATH=$backup; fi;",
]
if args.stop_existing:
parts.append("printf 'STOPPING\n' >\"$transaction\";")
parts.append(
stop_process_fragment(args.module_name, dxmain, args.daemon_script_path)
)
parts.append("printf 'STOPPED\n' >\"$transaction\";")
else:
parts.append(
"test -z \"$(pidof %s 2>/dev/null || true)\" || { echo PROCESS_RUNNING_REQUIRES_STOP; exit 70; };"
% q(args.module_name)
)
parts.extend(
[
"file_rollback_needed=1; printf 'REPLACING\n' >\"$transaction\";",
"mv -f \"$stage\" \"$dst\" || exit 55;",
"printf 'REPLACED\n' >\"$transaction\";",
"sync;",
"echo TARGET_SHA256=$(sha256sum \"$dst\" | awk '{print $1}');",
]
)
if args.start:
start_args = " ".join(q(value) for value in args.start_arg)
parts.append("\"$dst\" %s &" % start_args)
parts.append("sleep %d;" % args.observe_seconds)
parts.append(
"pidof %s >/dev/null 2>&1 || { echo PROCESS_NOT_RUNNING; exit 74; };"
% q(args.module_name)
)
parts.append("echo PROCESS_PIDS=$(pidof %s);" % q(args.module_name))
parts.extend(
[
"file_rollback_needed=0; process_recovery_needed=0;",
"printf 'COMPLETED\n' >\"$transaction\";",
"trap - 0;",
]
)
return " ".join(parts), backup, transaction
def restore_command(args: argparse.Namespace) -> Tuple[str, str]:
stamp = time.strftime("%Y%m%d-%H%M%S")
unique = stamp + "-" + uuid.uuid4().hex[:8]
target = args.target_path
backup = args.backup_path
stage = target + ".codex-restore-" + unique
before_restore = target + ".codex-before-restore-" + unique
transaction = "/tmp/codex-module-%s-restore-%s.state" % (args.module_name, unique)
parts = [
"backup=%s; dst=%s; stage=%s; before_restore=%s; transaction=%s;"
% (q(backup), q(target), q(stage), q(before_restore), q(transaction)),
"had_current=0; file_rollback_needed=0; process_recovery_needed=0; app_was_running=0; watchdog_was_running=0;",
rollback_function(
"if [ \"$had_current\" = 1 ]; then cp -p \"$before_restore\" \"$dst\"; else rm -f \"$dst\"; fi",
args,
),
"printf 'PREPARING\n' >\"$transaction\"; echo TRANSACTION_PATH=$transaction;",
"test -f \"$backup\" || { echo BACKUP_MISSING; exit 61; };",
"cp -p \"$backup\" \"$stage\" || exit 62;",
"test \"$(sha256sum \"$backup\" | awk '{print $1}')\" = \"$(sha256sum \"$stage\" | awk '{print $1}')\" || exit 63;",
"if [ -e \"$dst\" ]; then had_current=1; cp -p \"$dst\" \"$before_restore\" || exit 67; fi;",
]
if args.stop_existing:
parts.append("printf 'STOPPING\n' >\"$transaction\";")
parts.append(
stop_process_fragment(
args.module_name,
args.module_name.casefold() == "dxmain",
args.daemon_script_path,
)
)
parts.append("printf 'STOPPED\n' >\"$transaction\";")
else:
parts.append(
"test -z \"$(pidof %s 2>/dev/null || true)\" || { echo PROCESS_RUNNING_REQUIRES_STOP; exit 70; };"
% q(args.module_name)
)
parts.extend(
[
"file_rollback_needed=1; printf 'REPLACING\n' >\"$transaction\";",
"mv -f \"$stage\" \"$dst\" || exit 64;",
"printf 'REPLACED\n' >\"$transaction\";",
"sync;",
]
)
if args.start:
start_args = " ".join(q(value) for value in args.start_arg)
parts.extend(
[
"\"$dst\" %s &" % start_args,
"sleep %d;" % args.observe_seconds,
"pidof %s >/dev/null 2>&1 || exit 65;" % q(args.module_name),
]
)
if args.restart_daemon:
parts.append("%s || exit 66;" % recovery_start_fragment(args))
parts.extend(
[
"file_rollback_needed=0; process_recovery_needed=0;",
"printf 'COMPLETED\n' >\"$transaction\";",
"if [ \"$had_current\" = 1 ]; then rm -f \"$before_restore\"; fi;",
"trap - 0;",
]
)
return " ".join(parts), transaction
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="应用模块部署预检与受保护执行;默认不修改设备。"
)
parser.add_argument("--validate-only", action="store_true", help="只执行本地自检")
parser.add_argument("--host", help="目标设备 IP")
parser.add_argument("--expected-sn", help="目标设备完整 SN")
parser.add_argument("--expected-model", help="目标设备型号")
parser.add_argument("--module-name", help="模块进程/程序名称")
parser.add_argument("--source-path", help="设备可见的源文件绝对路径")
parser.add_argument("--target-path", help="设备目标文件绝对路径")
parser.add_argument(
"--action", choices=("preflight", "deploy", "restore"), default="preflight"
)
parser.add_argument("--backup-path", help="restore 动作使用的设备备份文件")
parser.add_argument("--execute", action="store_true", help="允许执行设备写操作")
parser.add_argument(
"--transport", choices=("serial-web", "telnet"), default="serial-web"
)
parser.add_argument("--serial-url", default="http://127.0.0.1:8766")
parser.add_argument("--serial-port", help="本次用户确认的 COM 口")
parser.add_argument("--confirm-serial-target", action="store_true")
parser.add_argument("--telnet-port", type=int, default=23)
parser.add_argument("--telnet-user", default="root")
parser.add_argument("--ssh-host", help="读取当前工程 product.json 的 SSH Host")
parser.add_argument("--project-path", help="当前设备对应的远程工程绝对路径")
parser.add_argument("--nfs-server", help="本次用户确认的 NFS 服务器")
parser.add_argument("--nfs-export", help="本次用户确认的 NFS 导出路径")
parser.add_argument("--nfs-mount", default="/mnt/nfs")
parser.add_argument("--mount-nfs", action="store_true")
parser.add_argument("--stop-existing", action="store_true")
parser.add_argument("--start", action="store_true")
parser.add_argument("--start-arg", action="append", default=[])
parser.add_argument("--observe-seconds", type=int, default=3)
parser.add_argument("--restart-daemon", action="store_true")
parser.add_argument(
"--daemon-start-command",
help="恢复时按当前设备实查得到的 daemon 启动命令;不提供硬编码默认值",
)
parser.add_argument(
"--daemon-script-path",
help="DxMain 在线调试前从当前设备实查得到的 daemon.sh 绝对路径",
)
parser.add_argument(
"--rollback-start-command",
help="普通进程停止后若部署失败,用于恢复原进程的实查启动命令",
)
parser.add_argument(
"--allow-limited-abi-check",
action="store_true",
help="设备缺少 readelf 时,明确允许只使用 file 和 uname 做有限 ABI 核验",
)
parser.add_argument("--allow-critical", action="store_true")
parser.add_argument("--connect-timeout", type=float, default=5.0)
parser.add_argument("--command-timeout", type=float, default=30.0)
parser.add_argument("--log-dir", help="本次任务日志目录")
return parser
def local_validate() -> None:
assert q("/mnt/nfs/a b") == "'/mnt/nfs/a b'"
assert "daemon_pids" in stop_process_fragment("DxMain", True, "/x/daemon.sh")
assert "SoftwareWatchdog" in stop_process_fragment("DxMain", True, "/x/daemon.sh")
assert "daemon_pids" not in stop_process_fragment("Demo", False)
assert "EmxCoreServer" in CRITICAL_MODULES
common = [
"--host", "192.0.2.1",
"--expected-sn", "TEST-SN",
"--expected-model", "EPM21C",
"--module-name", "DxMain",
"--target-path", "/mnt/emmc/DxMain",
"--log-dir", "/tmp/test-log",
"--serial-port", "COM1",
"--stop-existing",
"--daemon-script-path", "/root/firmware/scripts/daemon.sh",
"--daemon-start-command", "/root/firmware/scripts/daemon.sh 1",
]
parser = build_parser()
deploy_args = parser.parse_args(
common
+ ["--action", "deploy", "--source-path", "/mnt/nfs/DxMain", "--start"]
)
deploy_shell, _, deploy_state = deploy_command(deploy_args)
assert "ROLLBACK_COMPLETED" in deploy_shell and deploy_state.endswith(".state")
restore_args = parser.parse_args(
common
+ [
"--action", "restore",
"--backup-path", "/mnt/emmc/DxMain.bak",
"--restart-daemon",
]
)
restore_shell, restore_state = restore_command(restore_args)
assert "SoftwareWatchdog" in restore_shell and restore_state.endswith(".state")
bash_candidates = [
shutil.which("bash"),
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
]
bash_path = next(
(candidate for candidate in bash_candidates if candidate and Path(candidate).is_file()),
None,
)
if bash_path:
for label, shell_text in (
("deploy", deploy_shell),
("restore", restore_shell),
):
result = subprocess.run(
[bash_path, "-n"],
input=shell_text,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
raise AssertionError("%s Shell 语法错误:%s" % (label, result.stderr))
print("module_debug.py 本地自检通过")
def require_arguments(args: argparse.Namespace) -> None:
required = (
("host", "--host"),
("expected_sn", "--expected-sn"),
("expected_model", "--expected-model"),
("module_name", "--module-name"),
("target_path", "--target-path"),
("log_dir", "--log-dir"),
)
missing = [option for field, option in required if not getattr(args, field)]
if args.action != "restore" and not args.source_path:
missing.append("--source-path")
if args.action == "restore" and not args.backup_path:
missing.append("--backup-path")
if args.transport == "serial-web" and not args.serial_port:
missing.append("--serial-port")
if args.transport == "telnet":
if not args.ssh_host:
missing.append("--ssh-host")
if not args.project_path:
missing.append("--project-path")
if args.restart_daemon and not args.daemon_start_command:
missing.append("--daemon-start-command")
if args.stop_existing and args.action != "preflight":
if args.module_name.casefold() == "dxmain":
if not args.daemon_start_command:
missing.append("--daemon-start-command")
if not args.daemon_script_path:
missing.append("--daemon-script-path")
elif not args.rollback_start_command:
missing.append("--rollback-start-command")
if missing:
raise DebugError("缺少参数:" + ", ".join(missing))
def main(argv: Optional[Sequence[str]] = None) -> int:
configure_output()
parser = build_parser()
args = parser.parse_args(argv)
if args.validate_only:
local_validate()
return 0
require_arguments(args)
if args.observe_seconds < 0 or args.observe_seconds > 300:
raise DebugError("--observe-seconds 必须在 0 到 300 之间")
if not re.fullmatch(r"[A-Za-z0-9_.-]+", args.module_name):
raise DebugError("--module-name 只能包含字母、数字、点、下划线和连字符")
for value, label in (
(args.daemon_start_command, "--daemon-start-command"),
(args.rollback_start_command, "--rollback-start-command"),
):
if value and any(character in value for character in ("\r", "\n", "\x00")):
raise DebugError("%s 包含非法字符" % label)
if args.daemon_script_path:
validate_device_path(args.daemon_script_path, "--daemon-script-path")
if args.restart_daemon and args.action != "restore":
raise DebugError("--restart-daemon 只允许用于 restore 动作")
if args.restart_daemon and args.module_name.casefold() != "dxmain":
raise DebugError("--restart-daemon 只允许用于 DxMain 恢复")
if args.stop_existing and args.action != "preflight":
if args.module_name.casefold() == "dxmain":
if args.action == "deploy" and not args.start:
raise DebugError("DxMain 在线部署停止旧进程时必须同时指定 --start")
if args.action == "restore":
if not args.restart_daemon:
raise DebugError("DxMain 在线恢复必须指定 --restart-daemon")
if args.start:
raise DebugError("DxMain 在线恢复由 daemon 拉起,不能同时指定 --start")
elif not args.start:
raise DebugError("停止普通模块后必须同时指定 --start,避免成功返回时进程缺失")
validate_device_path(args.target_path, "--target-path")
validate_device_path(args.nfs_mount, "--nfs-mount")
if args.source_path:
validate_device_path(args.source_path, "--source-path")
if args.backup_path:
validate_device_path(args.backup_path, "--backup-path")
if is_critical_target(args.module_name, args.target_path) and args.action != "preflight":
if not args.allow_critical:
raise DebugError("关键服务默认拒绝替换;需要单独确认并传入 --allow-critical")
if args.action != "preflight" and not args.execute:
raise DebugError("deploy/restore 动作缺少 --execute,不会修改设备")
logger = EventLogger(Path(args.log_dir))
identity_verified = False
if args.transport == "telnet":
try:
discovery = broadcast_verify_identity(
args.host,
args.expected_sn,
args.expected_model,
args.connect_timeout,
)
logger.write("broadcast_identity_verified", summary=discovery)
except Exception as exc:
logger.write("broadcast_identity_failed", error=str(exc))
raise DebugError("Telnet 前必须完成 V2 广播身份核验") from exc
try:
response = query_identity(args.host, args.connect_timeout)
except Exception as exc:
logger.write("identity_failed", error=str(exc))
if args.transport == "telnet":
raise DebugError("Telnet 前必须完成协议身份核验") from exc
if args.action != "preflight" and not args.confirm_serial_target:
raise DebugError(
"协议身份核验失败;串口写操作需要用户确认物理目标并传入 --confirm-serial-target"
) from exc
else:
# 收到明确身份后,SN/型号冲突绝不允许被串口物理确认开关绕过。
verify_identity(response, args.expected_sn, args.expected_model)
identity_verified = True
logger.write("identity_verified", response=response)
session: Optional[DeviceSession] = None
try:
if args.transport == "serial-web":
session = SerialWebSession(
args.serial_url, args.serial_port, args.connect_timeout
)
logger.write(
"transport_ready",
transport="serial-web",
serial_port=args.serial_port,
identity_verified=identity_verified,
)
else:
username, password = load_telnet_credentials(
args.ssh_host,
args.project_path,
args.telnet_user,
args.connect_timeout,
)
session = TelnetSession(
args.host,
args.telnet_port,
username,
password,
args.connect_timeout,
)
logger.write("transport_ready", transport="telnet", user=username)
mount_command = nfs_mount_command(args)
if mount_command:
if not args.execute:
logger.write("nfs_mount_planned", command=mount_command)
else:
rc, output = session.run(mount_command, args.command_timeout)
logger.write("nfs_mount", returncode=rc, output=output)
if rc != 0:
raise DebugError("NFS 挂载失败")
if args.action in ("preflight", "deploy"):
rc, output = session.run(preflight_command(args), args.command_timeout)
logger.write("preflight", returncode=rc, output=output)
if rc != 0:
raise DebugError("设备端预检失败,退出码 %d" % rc)
else:
command = "if test -f %s; then ls -l %s; sha256sum %s; else echo BACKUP_MISSING; false; fi" % (
q(args.backup_path), q(args.backup_path), q(args.backup_path)
)
rc, output = session.run(command, args.command_timeout)
logger.write("restore_preflight", returncode=rc, output=output)
if rc != 0:
raise DebugError("恢复文件预检失败")
if args.action == "preflight":
logger.write("complete", conclusion="只读预检完成,设备未修改")
return 0
if args.action == "deploy":
command, backup, transaction = deploy_command(args)
logger.write(
"transaction_planned",
action="deploy",
transaction=transaction,
backup=backup,
)
try:
rc, output = session.run(
command, args.command_timeout + args.observe_seconds
)
except DebugError as exc:
logger.write(
"transaction_state_unknown",
transaction=transaction,
error=str(exc),
instruction="禁止直接重试;先读取设备事务状态并核对进程、文件哈希和备份",
)
raise
logger.write(
"deploy", returncode=rc, output=output, planned_backup=backup
)
if rc != 0:
raise DebugError("模块部署失败,退出码 %d" % rc)
else:
command, transaction = restore_command(args)
logger.write(
"transaction_planned", action="restore", transaction=transaction
)
try:
rc, output = session.run(
command, args.command_timeout + args.observe_seconds
)
except DebugError as exc:
logger.write(
"transaction_state_unknown",
transaction=transaction,
error=str(exc),
instruction="禁止直接重试;先读取设备事务状态并核对进程、文件哈希和备份",
)
raise
logger.write("restore", returncode=rc, output=output)
if rc != 0:
raise DebugError("模块恢复失败,退出码 %d" % rc)
logger.write("complete", conclusion="执行完成;仍需按任务功能判据验证")
return 0
finally:
if session is not None:
session.close()
if __name__ == "__main__":
try:
raise SystemExit(main())
except (DebugError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc:
print("错误:%s" % exc, file=sys.stderr)
raise SystemExit(2)