from __future__ import annotations from datetime import datetime, timezone import hashlib import os from pathlib import Path import platform import shlex import shutil import subprocess import sys from typing import Any from app.persistence import atomic_write_bytes MAX_FAILURE_LOG_BYTES = 1024 * 1024 FAILURE_LOG_RELATIVE = Path("ota") / "last-failure.log" _TRUNCATION_MARKER = ( "\n\n========== 日志已截断 ==========" "\n原始日志超过 1 MiB;保留开头和最新的失败尾部。" "\n========== 继续显示最新日志 ==========\n\n" ).encode("utf-8") _HEAD_BYTES = 128 * 1024 def _timestamp() -> str: return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") def sha256_file(path: Path) -> str: digest = hashlib.sha256() with Path(path).open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def bounded_failure_log(body: bytes, *, maximum: int = MAX_FAILURE_LOG_BYTES) -> bytes: normalized = bytes(body).decode("utf-8", errors="replace").encode("utf-8") if len(normalized) <= maximum: return normalized head = min(_HEAD_BYTES, maximum // 4) tail = maximum - head - len(_TRUNCATION_MARKER) if tail <= 0: return normalized[-maximum:] return normalized[:head] + _TRUNCATION_MARKER + normalized[-tail:] def failure_log_path(data_root: Path) -> Path: return Path(data_root) / FAILURE_LOG_RELATIVE def persist_failure_log(data_root: Path, runtime_log: Path) -> Path: source = Path(runtime_log) body = bounded_failure_log(source.read_bytes()) target = failure_log_path(data_root) atomic_write_bytes(target, body) try: target.chmod(0o600) except OSError: target.unlink(missing_ok=True) raise return target def clear_failure_log(data_root: Path) -> None: failure_log_path(data_root).unlink(missing_ok=True) def failure_log_metadata(data_root: Path, last_result: dict[str, Any] | None) -> tuple[bool, int]: if not isinstance(last_result, dict) or last_result.get("status") != "failed": return False, 0 path = failure_log_path(data_root) try: size = path.stat().st_size except OSError: return False, 0 if size <= 0 or size > MAX_FAILURE_LOG_BYTES: return False, 0 return True, size def read_failure_log(data_root: Path, last_result: dict[str, Any] | None) -> bytes | None: available, expected_size = failure_log_metadata(data_root, last_result) if not available: return None try: body = failure_log_path(data_root).read_bytes() except OSError: return None if len(body) != expected_size: return None return body class DiagnosticLog: def __init__(self, path: Path) -> None: self.path = Path(path) self._recent = bytearray() self._degraded = False def reset(self) -> None: self._recent.clear() try: self.path.parent.mkdir(parents=True, exist_ok=True) self.path.write_bytes(b"") except OSError: self._degraded = True self.write("OTA 诊断日志已创建") def write(self, message: str) -> None: rendered = str(message).replace("\r\n", "\n").replace("\r", "\n") entry = f"[{_timestamp()}] {rendered}\n".encode("utf-8", errors="replace") self._recent.extend(entry) del self._recent[:-MAX_FAILURE_LOG_BYTES] try: self.path.parent.mkdir(parents=True, exist_ok=True) restore = self._degraded or not self.path.exists() with self.path.open("wb" if restore else "ab") as handle: handle.write(bytes(self._recent) if restore else entry) self._degraded = False except OSError as exc: self._degraded = True try: # Avoid printing OS messages, paths or the command's content here. sys.stderr.write(f"OTA diagnostic storage unavailable ({type(exc).__name__}); recovery continues\n") except (OSError, UnicodeError): pass def persist(self, data_root: Path) -> Path: try: body = self.path.read_bytes() if not self._degraded else bytes(self._recent) except OSError: body = bytes(self._recent) target = failure_log_path(data_root) atomic_write_bytes(target, bounded_failure_log(body)) target.chmod(0o600) return target def section(self, name: str) -> None: self.write(f"========== {name} ==========") def host_summary(self, *, test_root: Path) -> None: self.section("安全主机摘要") self.write(f"system={platform.system()} release={platform.release()} machine={platform.machine()}") self.write(f"python={sys.version.splitlines()[0]}") for path in (Path("/opt"), Path("/run"), Path(test_root)): probe = path if path.exists() else path.parent try: usage = shutil.disk_usage(probe) self.write(f"space path={path} free={usage.free} total={usage.total}") except OSError as exc: self.write(f"space path={path} unavailable={exc.__class__.__name__}") def run( self, command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, check: bool = True, failure_label: str | None = None, log_output: bool = True, ) -> subprocess.CompletedProcess[bytes]: display = ( shlex.join([str(item) for item in command]) if log_output else f"{shlex.quote(str(command[0]))} <参数已按脱敏规则隐藏>" ) self.section(f"命令开始:{failure_label or Path(command[0]).name}") self.write(f"cwd={cwd if cwd is not None else os.getcwd()}") self.write(f"command={display}") try: result = subprocess.run( command, cwd=cwd, env=env, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) except OSError as exc: self.write(f"command_start_failed={exc.__class__.__name__}: {exc}") raise RuntimeError(f"{failure_label or '命令'}无法启动:{exc}") from exc output = result.stdout.decode("utf-8", errors="replace") if output and log_output: self.write("stdout_stderr:\n" + output.rstrip("\n")) try: sys.stdout.write(output) sys.stdout.flush() except (UnicodeError, OSError): pass elif output: self.write("stdout_stderr=<已按脱敏规则隐藏>") self.write(f"exit_code={result.returncode}") if check and result.returncode != 0: tail = ( output.rstrip()[-12000:] or "命令没有输出。" if log_output else "命令输出已按脱敏规则隐藏。" ) raise RuntimeError( f"{failure_label or '命令'}失败(退出码 {result.returncode}):\n{tail}" ) return result