- 源码打包 → 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 对比,方便判断缓存命中情况
423 lines
17 KiB
Python
423 lines
17 KiB
Python
#!/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)
|