Files
pear-admin-flask/deploy/nas/deploy.py
T
bwstudio fff78ef336 feat(deploy): 新增一键部署脚本 deploy/nas/deploy.py
- 源码打包 → SFTP 上传 → 解包覆盖 → 同步 favicon 种子 → 构建镜像 → 启动容器 → 健康检查
- 跳过禁止项目:.git / venv / data / .env / *.db / __pycache__ / 日志 / 图标缓存种子等
- 保留远程 .env 与 deploy/nas/data 数据卷(属主 uid=1001)
- 参数:
  --no-build     只更新代码重启容器(不改依赖时最快)
  --no-cache     强制不用 Docker 层缓存
  --pull         强制拉取基础镜像(默认不拉:NAS registry 常 401,本地缓存已够用)
  --icons-only   只同步 favicon 种子
  --check        只看远端状态
  --pack-only    只打包不上传(用于预览包内容)
- 临时包放 /tmp 目录避免项目根污染,失败也不影响部署结果
- 输出末尾打印旧/新镜像 ID 对比,方便判断缓存命中情况
2026-09-07 21:11:07 +08:00

423 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()
# 不加 --pullNAS 的 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)