#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Pear Admin Flask -> NAS (192.168.1.102) 一键构建部署脚本 做什么: 1. 本地把源码打成 tar.gz(排除 .git / venv / data / .env / *.db 等) 2. SFTP 上传到 NAS 3. 远端 sudo 解包覆盖源码(保留 .env 与 deploy/nas/data 数据卷) 4. 可选:同步本地已抓取的 favicon 种子到 deploy/nas/data/icon (NAS 容器无外网,抓不到 favicon,必须靠种子才有真实图标) 5. sudo docker compose build + up -d 6. 健康检查 /healthz 与 /site/ 前置条件: - 本机装了 paramiko: C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko - 能 ping 通 NAS 用法: # 完整流程(推荐):打包 + 传代码 + 传图标种子 + 构建 + 重启 + 体检 set SSH_PASS=你的密码 python deploy/nas/deploy.py # 只改了模板/静态文件,不需要重装依赖和编译镜像时,跳过 build 直接重启 python deploy/nas/deploy.py --no-build # 只同步图标种子,不动代码 python deploy/nas/deploy.py --icons-only # 只看远端当前状态(容器、镜像、健康),不改任何东西 python deploy/nas/deploy.py --check # 只打包不上传(看看包里有什么) python deploy/nas/deploy.py --pack-only 可用环境变量覆盖默认值: SSH_PASS SSH/sudo 密码(不设则交互输入) NAS_HOST 默认 192.168.1.102 NAS_USER 默认 bwadmin NAS_DIR 默认 /home/bwadmin/pear-admin-flask NAS_PORT 默认 22 注意: - NAS 上 bwadmin 没有 docker socket 权限,所有 docker 命令都走 sudo - deploy/nas/data 是数据卷,属主 uid 1001(= 容器内 appuser), 脚本会刻意跳过对它的 chown,否则容器将失去写权限 """ import argparse import getpass import io import os import stat import sys import tarfile import tempfile import time try: import paramiko except ImportError: sys.exit("缺少 paramiko,请先安装:\n" " C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko") # ---------------------------------------------------------------- 配置 PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) HOST = os.environ.get("NAS_HOST", "192.168.1.102") USER = os.environ.get("NAS_USER", "bwadmin") PORT = int(os.environ.get("NAS_PORT", "22")) REMOTE_DIR = os.environ.get("NAS_DIR", "/home/bwadmin/pear-admin-flask") REMOTE_TMP = "/tmp" COMPOSE_FILE = "deploy/nas/docker-compose.yaml" # 打包时排除的目录(相对项目根) EXCLUDE_DIRS = { ".git", "venv", ".venv", "env", "__pycache__", ".workbuddy", "instance", "flask_session", "logs", "output", "node_modules", ".idea", ".vscode", ".pytest_cache", ".mypy_cache", "build", "dist", "data", # 运行数据:SQLite / 图标缓存 / 上传,部署靠挂载,不打进包 } # 打包时排除的文件名 EXCLUDE_FILES = {".env", ".env.local", ".DS_Store", "pear.db", "padmin.db"} # 打包时排除的后缀 EXCLUDE_SUFFIX = (".pyc", ".pyo", ".log", ".db", ".db-wal", ".db-shm", ".bak", ".tar.gz", ".swp", ".pid") ICON_SRC = os.path.join(PROJECT_ROOT, "data", "icon") # 本地已抓取的 favicon ICON_DEST = "deploy/nas/data/icon" # 远端数据卷里的图标目录 ICON_OWNER = "1001:1001" # 容器内 appuser 的 uid:gid # ---------------------------------------------------------------- 工具 def log(msg=""): print(msg, flush=True) def step(msg): log("\n" + "=" * 62) log(" " + msg) log("=" * 62) def should_skip(rel_path, is_dir): """判断某个相对路径是否应被打进源码包""" parts = rel_path.replace("\\", "/").split("/") for p in parts: if p in EXCLUDE_DIRS: return True if not is_dir: name = parts[-1] if name in EXCLUDE_FILES: return True if name.endswith(EXCLUDE_SUFFIX): return True return False def pack_source(out_path): """把项目源码打成 tar.gz,返回 (文件路径, 文件数, 字节数)""" n = 0 with tarfile.open(out_path, "w:gz", compresslevel=6) as tar: for root, dirs, files in os.walk(PROJECT_ROOT): rel_root = os.path.relpath(root, PROJECT_ROOT) rel_root = "" if rel_root == "." else rel_root.replace("\\", "/") # 就地过滤目录,避免递归进 venv/.git 等大目录 dirs[:] = [d for d in dirs if not should_skip((rel_root + "/" + d) if rel_root else d, True)] # 打包脚本自身产生的临时包 dirs[:] = [d for d in dirs if d != os.path.basename(out_path)] for f in files: rel = (rel_root + "/" + f) if rel_root else f if should_skip(rel, False): continue full = os.path.join(root, f) if os.path.abspath(full) == os.path.abspath(out_path): continue try: tar.add(full, arcname=rel, recursive=False) n += 1 except (OSError, PermissionError) as e: log(" [跳过] %s (%s)" % (rel, e)) return out_path, n, os.path.getsize(out_path) def pack_icons(out_path): """把本地 data/icon/ 下的真实 favicon 打成 tar.gz(扁平结构,不带目录层级)""" if not os.path.isdir(ICON_SRC): return None files = [f for f in os.listdir(ICON_SRC) if os.path.isfile(os.path.join(ICON_SRC, f))] # 只带真实图片,字母头像 SVG 让容器自己生成即可 files = [f for f in files if not f.lower().endswith(".svg")] if not files: return None with tarfile.open(out_path, "w:gz") as tar: for f in files: tar.add(os.path.join(ICON_SRC, f), arcname=f, recursive=False) return out_path def human(n): for unit in ("B", "KB", "MB"): if n < 1024 or unit == "MB": return "%.1f %s" % (n, unit) n /= 1024.0 # ---------------------------------------------------------------- SSH class Nas: def __init__(self, host, user, pwd, port=22): self.pwd = pwd self.ssh = paramiko.SSHClient() self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.ssh.connect(host, port=port, username=user, password=pwd, timeout=20, allow_agent=False, look_for_keys=False) self.sftp = self.ssh.open_sftp() def _mask(self, text): """万一 sudo 把密码回显出来,别打在日志里""" return text.replace(self.pwd, "******") if self.pwd else text def run(self, cmd, timeout=900, show=True, sudo=False): """执行远程命令。sudo=True 时通过 -S 从 stdin 喂密码。""" full = ("sudo -S -p '' bash -lc %s" % _shq(cmd)) if sudo else ("bash -lc " + _shq(cmd)) chan = self.ssh.get_transport().open_session() chan.get_pty() chan.settimeout(timeout) chan.exec_command(full) if sudo: chan.sendall(self.pwd + "\n") buf = [] deadline = time.time() + timeout while True: if chan.recv_ready(): data = chan.recv(65536) if not data: break text = data.decode("utf-8", "replace") buf.append(text) if show: sys.stdout.write(self._mask(text)) sys.stdout.flush() elif chan.recv_stderr_ready(): data = chan.recv_stderr(65536) if not data: break text = data.decode("utf-8", "replace") buf.append(text) if show: sys.stdout.write(self._mask(text)) sys.stdout.flush() elif chan.exit_status_ready(): # 退出前把残留输出读干净 while chan.recv_ready(): buf.append(chan.recv(65536).decode("utf-8", "replace")) break else: if time.time() > deadline: chan.close() raise TimeoutError("远程命令超时(%ds):%s" % (timeout, cmd)) time.sleep(0.4) code = chan.recv_exit_status() chan.close() return code, "".join(buf) def upload(self, local, remote): self.sftp.put(local, remote) return remote def close(self): try: self.sftp.close() except Exception: pass self.ssh.close() def _shq(s): """把命令包成 bash -lc '...' 能安全接受的单一参数""" return "'" + s.replace("'", "'\"'\"'") + "'" # ---------------------------------------------------------------- 流程 def do_check(nas): step("远端状态检查") nas.run("docker ps --filter name=pear-admin-nas --format " "'table {{.Names}} {{.Status}} {{.Ports}}'", sudo=True) nas.run("docker images pear-admin-nas --format 'table {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}'", sudo=True) code, _ = nas.run("curl -fsS -o /dev/null -w 'healthz=%{http_code}\\n' http://127.0.0.1:5000/healthz; " "curl -fsS -o /dev/null -w 'site=%{http_code}\\n' http://127.0.0.1:5000/site/", sudo=False, show=True) return code def do_deploy(nas, local_pkg, remote_pkg, icon_pkg, do_build, args): # 1) 上传 step("上传源码包") nas.upload(local_pkg, remote_pkg) log(" -> %s (%s)" % (remote_pkg, human(os.path.getsize(local_pkg)))) # 2) 解包覆盖(保留 .env 与 deploy/nas/data) step("解包覆盖源码(保留 .env 与 deploy/nas/data)") nas.run("cd %s && tar xzf %s" % (REMOTE_DIR, remote_pkg), sudo=True) # 解压产生的文件属主可能是 root,改回 bwadmin;但必须跳过数据卷, # 否则容器(uid 1001)会失去对 pear.db 的写权限 nas.run("cd %s && find . -mindepth 1 -path './deploy/nas/data' -prune " "-o -exec chown %s:%s {} + 2>/dev/null || true" % (REMOTE_DIR, USER, USER), sudo=True) nas.run("rm -f %s" % remote_pkg, sudo=True) # 3) 图标种子 if icon_pkg: step("同步 favicon 种子(容器无外网,靠种子才有真实图标)") r_icon = REMOTE_TMP + "/" + os.path.basename(icon_pkg) nas.upload(icon_pkg, r_icon) nas.run("mkdir -p %s/%s && tar xzf %s -C %s/%s && chown -R %s %s/%s" % (REMOTE_DIR, ICON_DEST, r_icon, REMOTE_DIR, ICON_DEST, ICON_OWNER, REMOTE_DIR, ICON_DEST), sudo=True) cnt, _ = nas.run("ls -1 %s/%s | wc -l" % (REMOTE_DIR, ICON_DEST), sudo=True, show=False) log(" 图标种子已就位") nas.run("rm -f %s" % r_icon, sudo=True) else: log("\n[跳过] 本地没有图标种子(data/icon 为空),容器内图标将回退为字母头像") # 4) 构建 if do_build: step("构建镜像(docker compose build)") # 先记一下旧 ID,便于和构建后的结果对比 old_id, _ = nas.run("docker images -q pear-admin-nas:latest", sudo=True, show=False) old_id = (old_id or "").strip() # 不加 --pull(NAS 的 registry 镜像常返回 401),本地 python:3.11-slim 缓存已够用 pull = " --pull" if args.pull else "" pull += " --no-cache" if args.no_cache else "" # 单独收退出码,不用 pipe 截断输出(管道会把 stdout 读到一半就丢弃) build_cmd = ("cd %s && docker compose -f %s build%s" % (REMOTE_DIR, COMPOSE_FILE, pull)) code, _ = nas.run(build_cmd, sudo=True, timeout=1800) if code != 0: sys.exit("\n构建失败(退出码 %d),已中止部署(源码已更新到远端,但容器仍在跑旧镜像)" % code) new_id, _ = nas.run("docker images -q pear-admin-nas:latest", sudo=True, show=False) new_id = (new_id or "").strip() if old_id and new_id and old_id == new_id: log("\n[提示] 镜像 ID 未变化(%s):Docker 判定无改动复用了缓存。" % new_id[:12]) log(" 如果改了模板却没生效,试试 --no-cache 强制重建。") else: log("\n镜像已更新:%s -> %s" % ((old_id or "无")[:12], (new_id or "无")[:12])) else: log("\n[跳过] --no-build:不重新构建镜像,直接重启容器") # 5) 起容器 step("启动容器") nas.run("cd %s && docker compose -f %s up -d" % (REMOTE_DIR, COMPOSE_FILE), sudo=True) # 6) 等健康检查 step("等待健康检查") ok = False for i in range(30): time.sleep(4) code, out = nas.run("curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/healthz || true", sudo=False, show=False) status = out.strip().splitlines()[-1] if out.strip() else "" if status == "200": log(" 第 %d 次探测通过(healthz=200)" % (i + 1)) ok = True break log(" 第 %d 次探测未就绪(%s)" % (i + 1, status or "无响应")) if not ok: log("\n[警告] 健康检查未通过,打印容器日志:") nas.run("docker logs --tail 50 pear-admin-nas", sudo=True) return 1 # 7) 验收 step("验收") nas.run("docker ps --filter name=pear-admin-nas --format " "'table {{.Names}} {{.Status}} {{.Ports}}'", sudo=True) nas.run("curl -fsS -o /dev/null -w 'healthz=%{http_code}\\n' http://127.0.0.1:5000/healthz; " "curl -fsS -o /dev/null -w 'site=%{http_code}\\n' http://127.0.0.1:5000/site/", sudo=False) return 0 # ---------------------------------------------------------------- main def main(): ap = argparse.ArgumentParser(description="构建并部署 pear-admin-flask 到 NAS") ap.add_argument("--no-build", action="store_true", help="跳过镜像构建,只更新代码并重启") ap.add_argument("--no-cache", action="store_true", help="构建时不用 Docker 层缓存(改了模板却没生效时用)") ap.add_argument("--pull", action="store_true", help="构建时强制拉取基础镜像(默认不拉:NAS 的 registry 常返回 401," "本地 python:3.11-slim 缓存已够用)") ap.add_argument("--icons-only", action="store_true", help="只同步 favicon 种子") ap.add_argument("--no-icons", action="store_true", help="跳过 favicon 种子同步") ap.add_argument("--check", action="store_true", help="只看远端状态,不做任何修改") ap.add_argument("--pack-only", action="store_true", help="只打包不上传") args = ap.parse_args() os.chdir(PROJECT_ROOT) stamp = time.strftime("%Y%m%d-%H%M%S") if args.pack_only: pkg, n, size = pack_source("pear-src-%s.tar.gz" % stamp) log("已打包:%s(%d 个文件,%s)" % (pkg, n, human(size))) log("解包查看:tar tzf %s | head -50" % pkg) return 0 pwd = os.environ.get("SSH_PASS") or getpass.getpass("NAS %s@%s 密码: " % (USER, HOST)) if args.check: nas = Nas(HOST, USER, pwd, PORT) try: return do_check(nas) finally: nas.close() # 临时包放系统临时目录:既不污染项目根,也避免在项目目录里做删除动作 tmpdir = tempfile.gettempdir() pkg_path = None icon_path = None try: if not args.icons_only: step("本地打包源码") pkg_path, n, size = pack_source(os.path.join(tmpdir, "pear-src-%s.tar.gz" % stamp)) log(" %d 个文件,%s" % (n, human(size))) if not args.no_icons: icon_path = pack_icons(os.path.join(tmpdir, "pear-icons-%s.tar.gz" % stamp)) if icon_path: log(" 图标种子:%s(%s)" % (os.path.basename(icon_path), human(os.path.getsize(icon_path)))) nas = Nas(HOST, USER, pwd, PORT) try: if args.icons_only: if not icon_path: log("本地 data/icon 下没有可用的 favicon,无需同步") return 0 r_icon = REMOTE_TMP + "/" + os.path.basename(icon_path) nas.upload(icon_path, r_icon) nas.run("mkdir -p %s/%s && tar xzf %s -C %s/%s && chown -R %s %s/%s && rm -f %s" % (REMOTE_DIR, ICON_DEST, r_icon, REMOTE_DIR, ICON_DEST, ICON_OWNER, REMOTE_DIR, ICON_DEST, r_icon), sudo=True) log("\n图标种子已同步") return 0 return do_deploy(nas, pkg_path, REMOTE_TMP + "/" + os.path.basename(pkg_path), icon_path, not args.no_build, args) finally: nas.close() finally: # 清理临时包;失败也不影响部署结果(某些环境会拦截批量删除) for p in (pkg_path, icon_path): if p and os.path.exists(p): try: os.remove(p) except BaseException: log(" [提示] 临时包未能自动删除:%s" % p) if __name__ == "__main__": sys.exit(main() or 0)