From f4794e720ade23c9cefff0a13a28ad3f46333a2c Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 28 Mar 2026 12:02:44 -0500 Subject: [PATCH] Add Paramiko deploy/verify scripts for ARM board; gitignore deploy logs Made-with: Cursor --- .gitignore | 3 + tools/deploy_live_paramiko.py | 252 ++++++++++++++++++++++++++++++++++ tools/verify_live_console.py | 57 ++++++++ 3 files changed, 312 insertions(+) create mode 100644 tools/deploy_live_paramiko.py create mode 100644 tools/verify_live_console.py diff --git a/.gitignore b/.gitignore index 342f02d..554211e 100644 --- a/.gitignore +++ b/.gitignore @@ -172,6 +172,9 @@ node_modules/ # Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署) web-console/.next/ +# Paramiko 部署本地日志(可能含敏感命令回显) +.deploy_logs/ + # 无 PM2 时的本地进程状态 .process_runner/ diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py new file mode 100644 index 0000000..0a81013 --- /dev/null +++ b/tools/deploy_live_paramiko.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""通过 Paramiko(SFTP + exec)部署到 ARM 板,无交互 SSH。日志写入 .deploy_logs/。""" +from __future__ import annotations + +import os +import posixpath +import sys +from datetime import datetime +from pathlib import Path + +try: + import paramiko +except ImportError: + print("请先安装: pip install paramiko", file=sys.stderr) + sys.exit(1) + +HOST = os.environ.get("LIVE_DEPLOY_HOST", "192.168.21.105") +USER = os.environ.get("LIVE_DEPLOY_USER", "live") +PASS = os.environ.get("LIVE_DEPLOY_PASS", "12345678") +REMOTE_BASE = os.environ.get("LIVE_DEPLOY_PATH", "/home/live/douyinyoutube").rstrip("/") + +REPO_ROOT = Path(__file__).resolve().parent.parent + +FILES = [ + "src/web_process_backend.py", + "web.py", + "main.py", + "scripts/hardware_probe.py", + "douyin_youtube_ffplay.py", + "web-console/app/page.tsx", + "web-console/middleware.ts", +] + +LOG_DIR = REPO_ROOT / ".deploy_logs" +TAIL_LINES = 90 + + +def _safe_print(text: str) -> None: + try: + print(text) + except UnicodeEncodeError: + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + try: + sys.stdout.buffer.write(text.encode(enc, errors="replace") + b"\n") + except Exception: + sys.stdout.buffer.write(text.encode("ascii", errors="backslashreplace") + b"\n") + + +def _decode(data: bytes) -> str: + if not data: + return "" + try: + return data.decode("utf-8", errors="replace") + except Exception: + return data.decode("ascii", errors="backslashreplace") + + +def _tail(text: str, n: int = TAIL_LINES) -> str: + lines = text.splitlines() + if len(lines) <= n: + return text + return "\n".join(lines[-n:]) + + +def _log_append(path: Path, text: str) -> None: + with path.open("a", encoding="utf-8", errors="replace") as fh: + fh.write(text) + if not text.endswith("\n"): + fh.write("\n") + + +def mkdir_p(sftp: paramiko.SFTPClient, remote_dir: str) -> None: + remote_dir = remote_dir.replace("\\", "/").rstrip("/") + if not remote_dir or remote_dir == "/": + return + parts = [p for p in remote_dir.split("/") if p] + cur = "" + for p in parts: + cur = f"{cur}/{p}" if cur else f"/{p}" + try: + sftp.stat(cur) + except OSError: + try: + sftp.mkdir(cur) + except OSError: + pass + + +def sftp_put(sftp: paramiko.SFTPClient, local: Path, remote: str) -> None: + remote = remote.replace("\\", "/") + parent = posixpath.dirname(remote) + mkdir_p(sftp, parent) + sftp.put(str(local), remote) + + +def connect() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + HOST, + username=USER, + password=PASS, + timeout=30, + banner_timeout=40, + auth_timeout=30, + ) + return client, client.open_sftp() + + +def run_raw(ssh: paramiko.SSHClient, cmd: str, timeout: int) -> tuple[int, str, str]: + # 非交互;统一 UTF-8,减少远端 locale 乱码 + wrapped = ( + "export LC_ALL=C.UTF-8 LANG=C.UTF-8; " + "cd ~ 2>/dev/null || true; " + + cmd + ) + _stdin, stdout, stderr = ssh.exec_command(wrapped, timeout=timeout, get_pty=False) + out_b = stdout.read() + err_b = stderr.read() + code = stdout.channel.recv_exit_status() + return code, _decode(out_b), _decode(err_b) + + +def run_logged( + ssh: paramiko.SSHClient, + log_path: Path, + title: str, + cmd: str, + timeout: int = 120, +) -> tuple[int, str]: + block = f"\n========== {title} ==========\n$ {cmd}\n" + _log_append(log_path, block) + code, out, err = run_raw(ssh, cmd, timeout=timeout) + full = f"exit={code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}\n" + _log_append(log_path, full) + _safe_print(f"\n--- {title} (最后 {TAIL_LINES} 行) ---\n{_tail(full)}") + return code, full + + +def main() -> int: + LOG_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + log_path = LOG_DIR / f"deploy_{stamp}.log" + log_path.write_text(f"deploy {stamp} -> {USER}@{HOST}:{REMOTE_BASE}\n", encoding="utf-8") + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + _safe_print(f"完整日志: {log_path}") + + client, sftp = None, None + try: + client, sftp = connect() + except Exception as e: + _safe_print(f"连接失败: {e}") + _log_append(log_path, f"CONNECT FAIL: {e!r}\n") + return 1 + + try: + run_logged(client, log_path, "pwd", f"cd {REMOTE_BASE} && pwd", timeout=30) + run_logged(client, log_path, "id", "id", timeout=15) + run_logged(client, log_path, "hostname", "hostname", timeout=15) + run_logged( + client, + log_path, + "repo_exists", + f"test -d {REMOTE_BASE} && echo ok || echo MISSING", + timeout=15, + ) + + for rel in FILES: + lp = REPO_ROOT / rel + if not lp.is_file(): + _safe_print(f"本地缺少文件: {lp}") + return 2 + rp = f"{REMOTE_BASE}/{rel.replace(chr(92), '/')}" + _log_append(log_path, f"SFTP PUT {lp} -> {rp}\n") + sftp_put(sftp, lp, rp) + _safe_print(f"已上传: {rel}") + + py_compile = ( + f"cd {REMOTE_BASE} && python3 -m py_compile web.py " + "src/web_process_backend.py scripts/hardware_probe.py douyin_youtube_ffplay.py main.py" + ) + code_py, _ = run_logged(client, log_path, "python_py_compile", py_compile, timeout=90) + if code_py != 0: + _safe_print("远程 py_compile 失败,见日志") + return 3 + + build_cmd = ( + f"cd {REMOTE_BASE}/web-console && " + "(command -v pnpm >/dev/null 2>&1 && pnpm run build || npm run build)" + ) + code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=900) + if code_b != 0: + _safe_print("[警告] web-console build 非 0(可能未装 Node/pnpm),完整输出见日志") + + sudo_snap = ( + f"echo '{PASS}' | sudo -S bash -c " + f"'systemctl list-units --type=service --state=running 2>/dev/null | head -50 || true'" + ) + run_logged(client, log_path, "sudo_systemctl_running_head", sudo_snap, timeout=45) + + restart_cmd = ( + f"echo '{PASS}' | sudo -S bash -c '" + "systemctl try-restart live-console.service 2>/dev/null || true; " + "systemctl try-restart douyinyoutube.service 2>/dev/null || true; " + "systemctl try-restart douyin-live.service 2>/dev/null || true; " + "systemctl try-restart live-web.service 2>/dev/null || true; " + "echo try_restart_done'" + ) + run_logged(client, log_path, "sudo_try_restart_units", restart_cmd, timeout=60) + + run_logged( + client, + log_path, + "curl_health", + "curl -sS -m 10 http://127.0.0.1:8001/health 2>&1 || " + "curl -sS -m 10 http://127.0.0.1:8101/health 2>&1 || " + "echo 'curl_health_failed'", + timeout=20, + ) + + run_logged( + client, + log_path, + "pm2_or_process", + f"command -v pm2 >/dev/null 2>&1 && pm2 list || " + f"(pgrep -af 'launch.py|uvicorn|web.py' | head -15 || true)", + timeout=30, + ) + + run_logged( + client, + log_path, + "curl_server_info", + "curl -sS -m 10 'http://127.0.0.1:8001/server_info' 2>&1 | head -c 4000 || true", + timeout=20, + ) + + return 0 + finally: + if sftp: + sftp.close() + if client: + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_live_console.py b/tools/verify_live_console.py new file mode 100644 index 0000000..a2b4e96 --- /dev/null +++ b/tools/verify_live_console.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""重启 live-console 并验收 HTTP(Paramiko)。""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +import paramiko + +HOST = "192.168.21.105" +USER = "live" +PASS = "12345678" +LOG = Path(__file__).resolve().parent.parent / ".deploy_logs" / "verify_live_console.log" + + +def main() -> int: + LOG.parent.mkdir(parents=True, exist_ok=True) + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, username=USER, password=PASS, timeout=30) + + def run(cmd: str, timeout: int = 90) -> tuple[int, str, str]: + _, stdout, stderr = c.exec_command( + "export LC_ALL=C.UTF-8 LANG=C.UTF-8; " + cmd, + timeout=timeout, + ) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + block = f"\n$ {cmd}\nexit={code}\n--- out ---\n{out}\n--- err ---\n{err}\n" + with LOG.open("a", encoding="utf-8", errors="replace") as fh: + fh.write(block) + tail = "\n".join(block.splitlines()[-60:]) + print(tail) + return code, out, err + + try: + run( + f"echo '{PASS}' | sudo -S systemctl restart live-console.service", + timeout=120, + ) + time.sleep(5) + run("systemctl is-active live-console.service", timeout=20) + run("ss -tlnp 2>/dev/null | grep 8001 || true", timeout=20) + run( + "curl -sS -m 15 http://127.0.0.1:8001/health && echo " + "&& curl -sS -m 15 http://127.0.0.1:8001/server_info | head -c 1200", + timeout=40, + ) + return 0 + finally: + c.close() + + +if __name__ == "__main__": + raise SystemExit(main())