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 对比,方便判断缓存命中情况
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user