1096 lines
46 KiB
Python
1096 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
|
|
self.client_id = "module-debug-" + uuid.uuid4().hex
|
|
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?client=" + urllib.parse.quote(self.client_id))
|
|
)
|
|
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?client=" + urllib.parse.quote(self.client_id)
|
|
).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?client=" + urllib.parse.quote(self.client_id)
|
|
).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)
|