Commit 0f4af2e8 authored by wenwen.tang's avatar wenwen.tang 😕

feat(deploy): 部署改为服务器 Git 同步,Dockerfile 适配仓库根上下文

- deploy/Dockerfile:构建上下文由 app/ 布局改为**仓库根**布局
  · COPY rentos/ web/ assets/ templates/ build_notices.py
  · 新增 COPY tools/:服务器无 Python 3(yum 仅 3.6.8),台账迁移只能在容器内跑
  · wecom-cli 改为从仓库内 cli-linux-x64.tgz 解压,不再依赖未入库的 wecom/x/
- .dockerignore:新增。构建上下文变为仓库根,需裁剪 .git / data / output /
  samples / tools/shots* 等无关内容
- tools/deploy_git.py:新增 Git 同步部署脚本(setup / sync / pull / build / run /
  status / logs)。凭据经 SFTP 直写服务器 ~/.git-credentials(600),不经命令行;
  容器与验证逻辑复用 deploy_docker.py
parent 6538a8b7
Pipeline #834 canceled with stages
# ============================================================
# 构建上下文 = 仓库根,排除与镜像无关的内容以缩短构建时间
# 注意:deploy/wheels/ 与 deploy/wecom/ 是构建**必需**材料,切勿排除
# ============================================================
# 版本控制与工作区元数据
.git
.gitignore
.gitattributes
.workbuddy
# Python 缓存
__pycache__
**/__pycache__
*.pyc
*.pyo
# 运行期数据与产物(容器内由 volume 挂载,不进镜像)
data
output
uploads
logs
# 调研 / 样张 / 构建暂存,镜像不需要
samples
deploy/_stage
tools/shots
tools/shots2
tools/shots3
tools/shots4
tools/shots5
tools/shots6
*.bak
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# 租金通知系统 —— 云端镜像 # 租金通知系统 —— 云端镜像
# 底座用服务器本地已有的 python:3.11.3-slim(离线可用,无需拉取) # ---------------------------------------------------------------
# 依赖全部以 wheel 形式随包带入(openpyxl / reportlab / pillow / charset-normalizer); # 构建上下文 = **仓库根**(git 工作副本),路径直接取自仓库结构。
# wecom-cli 用官方 Linux 二进制随包带入。构建期不联网。 # 这样服务器只需 `git pull` 即可获得完整构建材料:
# deploy/wheels/ 离线 wheel(openpyxl / reportlab / pillow / charset-normalizer)
# deploy/wecom/*.tgz 企业微信官方 Linux CLI
# assets/fonts/simhei.ttf 中文字体(PDF 出单必需)
# 底座 python:3.11.3-slim 为服务器本地已有镜像,构建期零联网。
# ---------------------------------------------------------------
FROM python:3.11.3-slim FROM python:3.11.3-slim
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
...@@ -15,38 +20,45 @@ WORKDIR /app ...@@ -15,38 +20,45 @@ WORKDIR /app
# ---- 1. 离线安装运行期依赖 ---- # ---- 1. 离线安装运行期依赖 ----
# openpyxl 台账 / 批次数据导出 xlsx # openpyxl 台账 / 批次数据导出 xlsx
# reportlab + pillow 收费通知书直接绘制 PDF(2026-10-12 起统一 PDF,不再依赖 Office) # reportlab + pillow 收费通知书直接绘制 PDF(2026-10-12 起统一 PDF,不依赖 Office)
# charset-normalizer reportlab 的依赖 # charset-normalizer reportlab 的依赖
# 全部为 cp311 / py3 的 manylinux2014 wheel,apt 无新增(pillow wheel 自带图像库) # 全部为 cp311 / py3 的 manylinux wheel,apt 无新增(pillow wheel 自带图像库)
COPY wheels/ /wheels/ COPY deploy/wheels/ /wheels/
RUN pip install --no-index --find-links=/wheels \ RUN pip install --no-index --find-links=/wheels \
openpyxl reportlab pillow charset-normalizer \ openpyxl reportlab pillow charset-normalizer \
&& rm -rf /wheels && rm -rf /wheels
# ---- 2. 企业微信官方 CLI(Linux 静态二进制,@wecom/cli-linux-x64 1.3.4) ---- # ---- 2. 企业微信官方 CLI(Linux 静态二进制,@wecom/cli-linux-x64 1.3.4)----
# 放到 PATH 上,rentos/wecom.py 的 shutil.which("wecom-cli") 即可命中,无需改代码。 # 从仓库内 tgz 解压(包结构 package/bin/wecom-cli),落到 PATH 上即可被
# rentos/wecom.py 的 shutil.which("wecom-cli") 命中,无需改代码。
# 凭据**不打进镜像**,运行时由 /opt/rentos/wecom-config 挂到 /root/.config/wecom。 # 凭据**不打进镜像**,运行时由 /opt/rentos/wecom-config 挂到 /root/.config/wecom。
COPY wecom/wecom-cli /usr/local/bin/wecom-cli COPY deploy/wecom/cli-linux-x64.tgz /tmp/wecom.tgz
RUN chmod 755 /usr/local/bin/wecom-cli \ RUN tar xzf /tmp/wecom.tgz -C /tmp \
&& mv /tmp/package/bin/wecom-cli /usr/local/bin/wecom-cli \
&& chmod 755 /usr/local/bin/wecom-cli \
&& rm -rf /tmp/package /tmp/wecom.tgz \
&& /usr/local/bin/wecom-cli --version && /usr/local/bin/wecom-cli --version
# ---- 3. 应用代码 ---- # ---- 3. 应用代码(仓库根布局)----
COPY app/rentos/ /app/rentos/ COPY rentos/ /app/rentos/
COPY app/build_notices.py /app/build_notices.py COPY build_notices.py /app/build_notices.py
COPY app/web/ /app/web/ COPY web/ /app/web/
COPY app/assets/ /app/assets/ COPY assets/ /app/assets/
COPY app/templates/ /app/templates/ COPY templates/ /app/templates/
# 维护脚本随镜像带上:服务器无 Python 3(yum 仅 3.6.8,项目需 3.7+),
# 台账迁移 / 数据核对只能在容器内执行。
COPY tools/ /app/tools/
# ---- 3b. 构建期自检:中文字体随包带上 + PDF 渲染链可用 ---- # ---- 3b. 构建期自检:中文字体随包带上 + PDF 渲染链可用 ----
# simhei.ttf 是 asset 的一部分(约 9.7MB),缺失时会回落到 CID 字体、中文有缺字风险, # simhei.ttf 缺失时会回落到 CID 字体、中文有缺字风险;这里直接注册一次,
# 这里直接注册一次,构建失败即暴露问题,不要等到线上出单才发现。 # 构建失败即暴露问题,不要等到线上出单才发现。
RUN test -s /app/assets/fonts/simhei.ttf \ RUN test -s /app/assets/fonts/simhei.ttf \
&& python -c "from reportlab.pdfbase import pdfmetrics; \ && python -c "from reportlab.pdfbase import pdfmetrics; \
from reportlab.pdfbase.ttfonts import TTFont; \ from reportlab.pdfbase.ttfonts import TTFont; \
pdfmetrics.registerFont(TTFont('simhei','/app/assets/fonts/simhei.ttf')); \ pdfmetrics.registerFont(TTFont('simhei','/app/assets/fonts/simhei.ttf')); \
import PIL, reportlab; print('PDF 渲染链 OK: reportlab', reportlab.Version, '/ pillow', PIL.__version__)" import PIL, reportlab; print('PDF 渲染链 OK: reportlab', reportlab.Version, '/ pillow', PIL.__version__)"
# ---- 4. 运行期目录(实际由 volume 覆盖,此处保证镜像可独立运行) ---- # ---- 4. 运行期目录(实际由 volume 覆盖,此处保证镜像可独立运行)----
RUN mkdir -p /app/data /app/output /app/logs /app/uploads /app/output/exports \ RUN mkdir -p /app/data /app/output /app/logs /app/uploads /app/output/exports \
/root/.config/wecom /root/.config/wecom
......
# -*- coding: utf-8 -*-
"""通过 Git 把 rentos 同步部署到远程服务器(Docker)。
与 deploy_docker.py 的分工
deploy_docker.py 本机打包 → SFTP 上传 → 构建。不依赖服务器访问 Git,作**降级**用。
deploy_git.py 服务器自行 git pull → 构建。增量小、一键同步,**日常主用**。
前提:服务器能访问 Git 站点。实测 192.168.68.121 → git.thiztech.com 可直连
(DNS 走公网 IPv4,HTTPS 302 / 0.199s),服务器 git 1.8.3.1 可正常 clone/pull。
仓库内的构建材料(这些都已纳入版本管理,所以服务器 pull 后即可直接构建):
deploy/Dockerfile · deploy/wheels/ · deploy/wecom/*.tgz · assets/fonts/simhei.ttf
用法(密码一律走环境变量,不写进代码)
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py setup # 首次:clone + 落凭据
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py sync # 日常:pull → build → 重启 → 验证
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py pull # 只拉代码
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py build # 只构建镜像
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py run # 只重启容器 + 验证
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py status # 状态
RENTOS_SSH_PASSWORD=... python tools/deploy_git.py logs # 容器日志
首次 setup 还需 Git 凭据(之后由服务器端凭据缓存接管,无需再传):
RENTOS_GIT_PASSWORD=<git密码>
环境变量
RENTOS_SSH_PASSWORD 服务器密码(必填)
RENTOS_HOST 默认 192.168.68.121
RENTOS_USER 默认 root
RENTOS_REMOTE_DIR 默认 /opt/rentos
RENTOS_REPO_DIR 默认 $REMOTE_DIR/repo
RENTOS_GIT_URL 默认 https://git.thiztech.com/accounting/rentos.git
RENTOS_GIT_USER 默认 wenwen.tang@thizgroup.com
RENTOS_GIT_PASSWORD Git 密码(仅 setup 需要)
RENTOS_GIT_BRANCH 默认 main
数据安全
容器挂载的 data/ output/ logs/ uploads/ wecom-config/ 都在 $REMOTE_DIR 下、
**不在 repo 内**,因此 git pull / reset --hard **不会碰线上数据**。
"""
import argparse
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import paramiko # noqa: E402
import deploy_docker as D # noqa: E402 复用 SSH / 容器 / 验证
REMOTE_DIR = D.REMOTE_DIR
REPO_DIR = os.environ.get("RENTOS_REPO_DIR", REMOTE_DIR + "/repo")
GIT_URL = os.environ.get("RENTOS_GIT_URL",
"https://git.thiztech.com/accounting/rentos.git")
GIT_USER = os.environ.get("RENTOS_GIT_USER", "wenwen.tang@thizgroup.com")
GIT_PASS = os.environ.get("RENTOS_GIT_PASSWORD", "")
BRANCH = os.environ.get("RENTOS_GIT_BRANCH", "main")
# 服务器上凭据文件的主机名(store 格式:https://用户:密码@主机)
GIT_HOST = GIT_URL.replace("https://", "").replace("http://", "").split("/")[0]
def log(msg=""):
print(msg, flush=True)
def sh(cli, cmd, timeout=1800, quiet=False):
return D.run(cli, cmd, timeout=timeout, quiet=quiet)
# --------------------------------------------------------------------- 凭据
def write_credentials(cli):
"""把 Git 凭据写到服务器 ~/.git-credentials(600)。
走 SFTP 直写文件,**不经命令行**,避免密码出现在进程列表里。
"""
if not GIT_PASS:
log(" !! 未提供 RENTOS_GIT_PASSWORD")
log(" 若服务器凭据缓存已存在可忽略;否则请用环境变量传入后重跑 setup")
return False
user = GIT_USER.replace("@", "%40") # 用户名里的 @ 必须编码
line = "https://%s:%s@%s\n" % (user, GIT_PASS, GIT_HOST)
sf = cli.open_sftp()
sh(cli, "umask 077 && touch ~/.git-credentials && chmod 600 ~/.git-credentials",
quiet=True)
with sf.file("/root/.git-credentials", "w") as f:
f.write(line)
sf.chmod("/root/.git-credentials", 0o600)
sf.close()
sh(cli, "git config --global credential.helper store", quiet=True)
log(" 凭据已写至服务器 ~/.git-credentials(600),helper=store")
return True
def check_credentials(cli):
"""确认服务器凭据缓存里已有该站点。"""
rc, o, _ = sh(cli, "cat ~/.git-credentials 2>/dev/null | sed 's/:[^:@]*@/:***@/'",
quiet=True)
ok = GIT_HOST in o and ":***@" in o
log(" 凭据缓存:%s" % ("已就绪" if ok else "缺失"))
return ok
# --------------------------------------------------------------------- 仓库
def ensure_repo(cli, init=False):
"""确保服务器上存在可用的 git 工作副本;init=True 时才 clone。"""
rc, o, _ = sh(cli, "test -d %s/.git && echo YES || echo NO" % REPO_DIR, quiet=True)
exists = "YES" in o
if exists:
log(" 仓库已存在:%s" % REPO_DIR)
rc, o, _ = sh(cli, "cd %s && git remote get-url origin" % REPO_DIR, quiet=True)
log(" origin = %s" % o.strip())
return True
if not init:
log(" !! %s 不存在,请先执行 setup" % REPO_DIR)
return False
if not check_credentials(cli):
write_credentials(cli)
sh(cli, "mkdir -p %s" % REMOTE_DIR, quiet=True)
log(" 首次 clone …")
rc, o, e = sh(cli, "cd %s && GIT_TERMINAL_PROMPT=0 git clone -b %s %s repo 2>&1 | tail -8"
% (REMOTE_DIR, BRANCH, GIT_URL), timeout=900)
if rc != 0:
log(" !! clone 失败(rc=%d)" % rc)
return False
return True
def pull(cli):
D.step("拉取最新代码")
if not ensure_repo(cli):
return False
# 部署副本不应有本地改动;用 fetch + reset --hard 强制与远端对齐,
# 避免历史遗留的本地修改导致 pull 冲突。
rc, o, e = sh(cli,
"cd %s && GIT_TERMINAL_PROMPT=0 git fetch -q origin %s && "
"git reset -q --hard origin/%s && git log --oneline -1 && "
"echo '--- 本次变更文件 ---' && "
"git diff --name-only HEAD@{1} HEAD 2>/dev/null | head -20 || true"
% (REPO_DIR, BRANCH, BRANCH), timeout=900)
if rc != 0:
log(" !! 拉取失败")
return False
return True
# --------------------------------------------------------------------- 构建
def build(cli):
D.step("构建镜像")
log(" 构建上下文:%s(仓库根,.dockerignore 已裁剪)" % REPO_DIR)
log(" 基础镜像:python:3.11.3-slim(服务器本地已有,无需拉取)")
rc, o, e = sh(cli,
"cd %s && docker build -t %s . 2>&1 | tail -28" % (REPO_DIR, D.IMAGE),
timeout=1800)
if rc != 0:
log("!! 构建失败(rc=%d)" % rc)
return False
rc2, o2, _ = sh(cli, "docker images %s --format '{{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}'"
% D.IMAGE, quiet=True)
log(" 镜像就绪:" + o2.strip())
return True
# --------------------------------------------------------------------- 验证
_PY_PDF = (
"import rentos.notice_pdf as P, rentos.service, rentos.scheduler\n"
"print(' 字体文件:', P.font_path())\n"
"print(' 字体名 :', P.font_name())\n"
"print(' 模块导入: rentos.service / scheduler OK')\n"
"from rentos import tables\n"
"t = tables.load_table('rent_parking')\n"
"r = (t or {}).get('rows') or []\n"
"cols = (t or {}).get('columns') or []\n"
"print(' 台账: %d 行 x %d 列' % (len(r), len(cols)))\n"
"print(' AJ 有值:', sum(1 for x in r if x.get('AJ')))\n"
)
def verify(cli):
D.verify(cli)
D.step("6b/6 新增能力自检(PDF 出单链 + 台账列)")
rc, o, e = sh(cli, "docker exec %s python -c %s" % (D.CONTAINER, D._q(_PY_PDF)))
if rc != 0:
log(" !! 自检失败")
rc2, o2, _ = sh(cli, "docker exec %s wecom-cli --version" % D.CONTAINER, quiet=True)
log(" 企微 CLI:%s" % (o2.strip() or "未就绪"))
log("")
log(" 访问地址: http://%s:%d/ (账号 admin / 1989@rent05)" % (D.HOST, D.APP_PORT))
# --------------------------------------------------------------------- 命令
def cmd_setup(_a):
cli = D.connect()
try:
D.step("初始化服务器 Git 工作副本")
sh(cli, "mkdir -p %s" % REMOTE_DIR, quiet=True)
if not write_credentials(cli):
log(" 跳过凭据写入(可能已存在)")
check_credentials(cli)
if ensure_repo(cli, init=True):
sh(cli, "cd %s && git log --oneline -1 && git ls-files | wc -l" % REPO_DIR)
log("")
log(" ✓ 完成。下一步:python tools/deploy_git.py sync")
finally:
cli.close()
def cmd_pull(_a):
cli = D.connect()
try:
pull(cli)
finally:
cli.close()
def cmd_build(_a):
cli = D.connect()
try:
build(cli)
finally:
cli.close()
def cmd_run(_a):
cli = D.connect()
try:
D.run_container(cli)
D.open_firewall(cli)
verify(cli)
finally:
cli.close()
def cmd_status(_a):
cli = D.connect()
try:
D.step("状态")
sh(cli, "cd %s 2>/dev/null && git log --oneline -1 && git status -sb | head -3"
% REPO_DIR)
sh(cli, "docker ps -a --filter name=%s --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'"
% D.CONTAINER)
sh(cli, "docker images %s --format '{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}'"
% D.IMAGE)
sh(cli, "curl -s -o /dev/null -w 'host HTTP %%{http_code}\\n' http://127.0.0.1:%d/"
% D.APP_PORT)
finally:
cli.close()
def cmd_logs(_a):
cli = D.connect()
try:
D.run(cli, "docker logs --tail 60 %s" % D.CONTAINER)
finally:
cli.close()
def cmd_sync(_a):
cli = D.connect()
try:
if not pull(cli):
sys.exit("拉取失败,已停止。")
if not build(cli):
sys.exit("构建失败,已停止。")
D.run_container(cli)
D.open_firewall(cli)
verify(cli)
finally:
cli.close()
D.step("完成")
log(" 以后只需:RENTOS_SSH_PASSWORD=... python tools/deploy_git.py sync")
def main():
ap = argparse.ArgumentParser(description="rentos → 远程服务器(Git 同步部署)")
sub = ap.add_subparsers(dest="cmd")
for name, fn in (("setup", cmd_setup), ("sync", cmd_sync), ("pull", cmd_pull),
("build", cmd_build), ("run", cmd_run),
("status", cmd_status), ("logs", cmd_logs)):
p = sub.add_parser(name)
p.set_defaults(func=fn)
args = ap.parse_args()
if not getattr(args, "func", None):
ap.print_help()
return
args.func(args)
if __name__ == "__main__":
main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment