#!/usr/bin/env python3 # -*- coding: utf-8 -*- """通过 Paramiko(SFTP + exec)部署到 ARM 板,无交互 SSH。日志写入 .deploy_logs/。""" from __future__ import annotations import io import os import posixpath import sys import time 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 def _discover_repo_files() -> list[str]: """与 youtube.py 运行相关的源码,避免只同步白名单时缺模块。""" extra: list[str] = [] backup_dir = REPO_ROOT / "backup" if backup_dir.is_dir(): for p in sorted(backup_dir.glob("*.py")): extra.append(str(p.relative_to(REPO_ROOT)).replace("\\", "/")) return extra FILES = [ "src/utils.py", "src/logger.py", "src/spider.py", "src/stream.py", "src/proxy.py", "src/http_clients/async_http.py", "src/http_clients/sync_http.py", "src/android_control.py", "src/scrcpy_stream.py", "src/async_thread_loop.py", "src/youtube_ini_sync.py", "src/control_plane.py", "src/web_process_backend.py", "src/hub_routes.py", "src/hub_kv_store.py", "src/initializer.py", "src/live_config/__init__.py", "src/live_config/loader.py", "web.py", "youtube.py", "tiktok.py", "main.py", "ffmpeg_install.py", "scripts/launch.py", "scripts/env_check.py", "scripts/hardware_probe.py", "config/hardware.env.example", "ecosystem.config.cjs", "docs/install-profiles.md", "douyin_youtube_ffplay.py", "scripts/linux/install_hub.sh", "scripts/linux/upgrade_ffmpeg_newer.sh", "scripts/linux/reinstall_live_edge.sh", "scripts/linux/service_ctl.sh", "scripts/linux/lib/common.sh", "services/srs/docker-compose.yml", "services/srs/data/index.html", "services/neko/docker-compose.yml", "services/neko/chromium.conf", "services/neko/policies/policies.json", "services/neko/.gitignore", "live-platform/tools/render_caddyfile.py", "live-platform/tools/render_stack_env.py", "web-console/package.json", "web-console/package-lock.json", "web-console/app/globals.css", "web-console/app/layout.tsx", "web-console/app/page.tsx", "web-console/middleware.ts", "web-console/components/OverviewCharts.tsx", "web-console/components/live-product/LiveControlApp.tsx", "web-console/components/live-product/LiveAndroidWorkstation.tsx", "web-console/components/live-product/LiveAndroidStreamConsole.tsx", "web-console/components/live-product/LiveYoutubeUnmannedView.tsx", "web-console/components/live-product/LiveProcessLiveLogCard.tsx", "web-console/components/live-product/LiveTiktokHdmiView.tsx", "web-console/lib/panelUrls.ts", "web-console/lib/androidScrcpyWs.ts", "web-console/lib/scrcpyH264Decoder.ts", "web-console/lib/youtubeProChannels.ts", "web-console/lib/youtubeConfigUtils.ts", "web-console/components/console/LiveConsoleWorkspace.tsx", ] FILES = sorted(set(FILES + _discover_repo_files())) 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 _shell_bytes_lf(data: bytes) -> bytes: """Windows 工作区常见 CRLF;上传到 Linux 后 bash 会把 pipefail\\r 当成非法选项。""" return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") def sftp_put(sftp: paramiko.SFTPClient, local: Path, remote: str) -> None: remote = remote.replace("\\", "/") parent = posixpath.dirname(remote) mkdir_p(sftp, parent) if local.suffix.lower() == ".sh": sftp.putfo(io.BytesIO(_shell_bytes_lf(local.read_bytes())), remote) else: 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}") run_logged( client, log_path, "clear_python_caches", f"rm -rf {REMOTE_BASE}/src/__pycache__ {REMOTE_BASE}/backup/__pycache__ 2>/dev/null; echo cache_cleared", timeout=20, ) run_logged( client, log_path, "verify_utils_py_snippet", f"sed -n '1,28p' {REMOTE_BASE}/src/utils.py", timeout=15, ) py_compile = ( f"cd {REMOTE_BASE} && python3 -m py_compile web.py youtube.py tiktok.py ffmpeg_install.py " "backup/__init__.py backup/msg_push.py backup/robust_relay.py " "src/utils.py src/logger.py src/spider.py src/stream.py src/proxy.py " "src/http_clients/async_http.py src/http_clients/sync_http.py " "src/control_plane.py src/web_process_backend.py src/hub_routes.py src/hub_kv_store.py " "src/async_thread_loop.py src/youtube_ini_sync.py " "src/live_config/__init__.py src/live_config/loader.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 _log_append( log_path, "\n[提示] web_console_build 在远端执行 npm + next build,ARM 上常需 5~20 分钟;" "未完成前日志不会出现 exit=,属正常现象。\n", ) _safe_print("远端正在构建 web-console(请耐心等待,ARM 可能较久)…") build_cmd = ( f"cd {REMOTE_BASE}/web-console && " "(npm ci 2>/dev/null || npm install) && " "(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=2400) 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 live-edge.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, "sudo_restart_live_console", f"echo '{PASS}' | sudo -S systemctl restart live-console.service", timeout=120, ) _safe_print("等待控制面监听(最多约 45s)…") time.sleep(4) run_logged( client, log_path, "curl_health_after_restart", "for i in $(seq 1 22); do " "if curl -sf -m 4 http://127.0.0.1:8001/health >/dev/null 2>&1; then echo \"health_ok_8001\"; exit 0; fi; " "if curl -sf -m 4 http://127.0.0.1:8101/health >/dev/null 2>&1; then echo \"health_ok_8101\"; exit 0; fi; " "sleep 2; " "done; echo curl_health_failed; exit 0", timeout=120, ) # 若健康检查失败,再尝试 reset-failed + start(部分板子 restart 后 unit 未 active) recover_console = ( f"echo '{PASS}' | sudo -S bash -c '" "systemctl reset-failed live-console.service 2>/dev/null; " "systemctl start live-console.service 2>/dev/null; " "sleep 5; " "systemctl is-active live-console.service || true; " "journalctl -u live-console.service -n 25 --no-pager 2>/dev/null || true'" ) run_logged(client, log_path, "recover_live_console_if_needed", recover_console, timeout=90) run_logged( client, log_path, "curl_health_after_recover", "for i in $(seq 1 12); do " "if curl -sf -m 4 http://127.0.0.1:8001/health >/dev/null 2>&1; then echo health_ok_recover; exit 0; fi; " "sleep 2; done; echo still_failed; exit 0", timeout=60, ) run_logged( client, log_path, "curl_port80_root", "curl -sS -m 12 -o /dev/null -w 'http_code=%{http_code}\\n' http://127.0.0.1/ 2>&1 || echo curl_80_fail", timeout=20, ) run_logged( client, log_path, "chmod_reinstall_edge", f"chmod +x {REMOTE_BASE}/scripts/linux/reinstall_live_edge.sh 2>/dev/null || true", timeout=15, ) run_logged( client, log_path, "reinstall_live_edge", f"echo '{PASS}' | sudo -S bash {REMOTE_BASE}/scripts/linux/reinstall_live_edge.sh", timeout=180, ) # Caddy 刚 enable --now 时 bind :80 可能晚于立即 curl(曾出现 http_code=000) time.sleep(12) run_logged( client, log_path, "curl_port80_after_edge", "for i in 1 2 3 4 5 6 7 8; do " "code=$(curl -sS -m 10 -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo 000); " "echo \"try$i http_code=$code\"; " "[ \"$code\" = 200 ] && exit 0; " "sleep 4; " "done; exit 1", timeout=120, ) 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, ) run_logged( client, log_path, "docker_srs_neko_ps", "docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -E 'live-srs|live-neko' || echo '(no live-srs / live-neko in docker ps)'", timeout=25, ) return 0 finally: if sftp: sftp.close() if client: client.close() if __name__ == "__main__": raise SystemExit(main())