diff --git a/deploy/nas/deploy.py b/deploy/nas/deploy.py new file mode 100644 index 0000000..b6b5f40 --- /dev/null +++ b/deploy/nas/deploy.py @@ -0,0 +1,422 @@ +#!/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)