207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
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
|