diff --git a/push/push_to_dev.py b/push/push_to_dev.py new file mode 100644 index 0000000..0d0087e --- /dev/null +++ b/push/push_to_dev.py @@ -0,0 +1,464 @@ +# -*- coding: utf-8 -*- +""" +safety-eval-service 一键发布到开发环境 K8s 脚本 +================================================ +在本地 Windows 执行,自动完成: + 本地 Maven 编译 (可选) -> 上传 JAR -> 远程 Docker build -> 推送 ACR + -> 更新 K8s Deployment -> 滚动重启 -> 校验 Pod 状态 + +与 docs/build-tools/build_push.py 的区别: + build_push.py 只构建并推送镜像,最后只"打印" kubectl 命令; + 本脚本会真正执行 K8s 部署、滚动更新并校验结果。 + +前置条件: + - Python 3 + paramiko (pip install paramiko) + - 本地已编译好 JAR,或加 --build 让脚本自动 mvn 编译 + +用法: + # 最简单: 自动查找项目 JAR 并发布 (推荐) + python push_to_dev.py + + # 指定 JAR 路径 + python push_to_dev.py --jar "D:\\workspace\\code\\safety-eval-service\\safety-eval-start\\target\\safety-eval-start-1.0-SNAPSHOT.jar" + + # 先本地编译再发布 + python push_to_dev.py --build + + # 自定义版本序号 (默认自动递增当天序号) + python push_to_dev.py --version 3 + + # 只更新 K8s (镜像已存在,只触发滚动重启) + python push_to_dev.py --deploy-only --image <完整镜像地址> + + # 跳过 K8s 部署,只构建推送镜像 + python push_to_dev.py --no-deploy + +示例输出镜像: + jjb-registry-registry.cn-hangzhou.cr.aliyuncs.com/ali_img_ns/prod-aly-ota-dragon-jjb-saas-safety-eval:ota-20260707-1 +""" + +import argparse +import os +import subprocess +import sys +import time +from datetime import datetime + +import paramiko + +# ==================== 配置区 ==================== +# K8s Master (SSH) +MASTER = "192.168.20.100" +SSH_USER = "root" +SSH_PASSWD = "Zcloud@zcloud100" + +# 杭州 ACR (K8s image-pull-secret 指向此处,必须用这个仓库) +ACR_REGISTRY = "jjb-registry-registry.cn-hangzhou.cr.aliyuncs.com" +ACR_NAMESPACE = "ali_img_ns" +ACR_USER = "10952138@qq.com" +ACR_PASS = "idurCT!rIq9EzISD" + +# K8s 部署信息 +K8S_NAMESPACE = "jjb-dragon" +DEPLOY_NAME = "jjb-saas-safety-eval-deploy" # Deployment 名称 +CONTAINER_NAME = "jjb-saas-safety-eval-app" # 容器名称 +KUBECTL = "/usr/bin/kubectl" + +# 镜像命名 +APP_NAME = "jjb-saas-safety-eval" +ENV_PREFIX = "ota" # 环境前缀 + +# 项目本地路径 (默认为脚本所在目录的上一级) +PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# JAR 相对路径 +JAR_REL = os.path.join("safety-eval-start", "target", "safety-eval-start-1.0-SNAPSHOT.jar") + +# 远程构建目录 +REMOTE_BUILD_DIR = f"/tmp/docker-build-{APP_NAME}" + +# 脚本自带的 Dockerfile (与 docs/build-tools/Dockerfile 一致: CentOS7 + JDK1.8.0_202) +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +DOCKERFILE_PATH = os.path.join(SCRIPT_DIR, "Dockerfile") +# 若 push 目录没有 Dockerfile,则回退到 docs/build-tools/Dockerfile +if not os.path.exists(DOCKERFILE_PATH): + DOCKERFILE_PATH = os.path.join( + PROJECT_DIR, "docs", "build-tools", "Dockerfile" + ) + + +# ==================== 工具函数 ==================== +def ssh_exec(client, cmd, timeout=600, quiet=False): + """执行 SSH 命令并打印结果,返回 (stdout, stderr, exit_code)。""" + if not quiet: + print(f" $ {cmd[:150]}{'...' if len(cmd) > 150 else ''}") + stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + exit_code = stdout.channel.recv_exit_status() + if not quiet: + if out.strip(): + for line in out.strip().split("\n")[-15:]: + print(f" {line}") + if err.strip(): + # 过滤掉 docker 下载进度噪音 + important = [ + l for l in err.strip().split("\n") + if not any(x in l for x in + ["Downloading", "Downloaded", "Extracting", "Progress", + "WARNING! --password", "Login Succeeded"]) + ] + if important: + print(f" [stderr] {'; '.join(important[:5])}") + return out.strip(), err.strip(), exit_code + + +def upload_with_progress(sftp, local, remote): + """带进度条的上传。""" + size_mb = os.path.getsize(local) / 1024 / 1024 + start = time.time() + last_pct = [-1] + + def progress(transferred, total): + pct = int(transferred * 100 / total) + if pct >= last_pct[0] + 10: + last_pct[0] = pct + mb = transferred / 1024 / 1024 + elapsed = time.time() - start + speed = mb / elapsed if elapsed > 0 else 0 + print(f" {pct}% ({mb:.1f} MB) - {speed:.1f} MB/s") + + sftp.put(local, remote, callback=progress) + elapsed = time.time() - start + print(f" 上传完成: {elapsed:.1f}s ({size_mb / elapsed:.1f} MB/s)") + + +def find_local_jar(project_dir): + """自动查找项目 JAR 包。""" + candidate = os.path.join(project_dir, JAR_REL) + if os.path.exists(candidate): + return os.path.abspath(candidate) + # 兜底: 在 safety-eval-start/target 下找最大的 jar (非 *.original) + target_dir = os.path.join(project_dir, "safety-eval-start", "target") + if os.path.isdir(target_dir): + jars = [os.path.join(target_dir, f) for f in os.listdir(target_dir) + if f.endswith(".jar") and not f.endswith(".jar.original")] + if jars: + jars.sort(key=os.path.getsize, reverse=True) + return os.path.abspath(jars[0]) + return None + + +def local_mvn_build(project_dir): + """在本地执行 Maven 编译。""" + print("[0/7] 本地 Maven 编译 (mvn clean package -DskipTests)...") + cmd = ["mvn", "clean", "package", "-DskipTests"] + print(f" $ {' '.join(cmd)}") + print(" (编译日志较多,仅显示关键输出...)") + try: + proc = subprocess.run( + cmd, cwd=project_dir, shell=(os.name == "nt"), + capture_output=True, text=True, encoding="utf-8", errors="replace" + ) + except FileNotFoundError: + print(" ERROR: 未找到 mvn 命令,请确认本地已安装 Maven 并加入 PATH。") + sys.exit(1) + + # 打印最后的关键行 + tail = (proc.stdout or "").strip().split("\n")[-25:] + for line in tail: + print(f" {line}") + if proc.returncode != 0: + print("\n ERROR: Maven 编译失败!") + if proc.stderr: + print(proc.stderr[-1000:]) + sys.exit(1) + print(" Maven 编译成功。") + + +def next_version_suffix(client): + """查询 master 上当天已存在的镜像 tag,自动递增序号。""" + date_tag = datetime.now().strftime("%Y%m%d") + prefix = f"{ENV_PREFIX}-{date_tag}-" + # 查询本应用当天已构建的 tag (docker images 第二列为 Tag) + repo_filter = f"prod-aly-{ENV_PREFIX}-dragon-{APP_NAME}" + out, _, _ = ssh_exec( + client, + f"docker images --format '{{{{.Repository}}}}:{{{{.Tag}}}}' " + f"| grep '{repo_filter}' | awk -F: '{{print $NF}}'", + quiet=True, + ) + used = set() + for line in out.split("\n"): + line = line.strip() + if line.startswith(prefix): + num_part = line[len(prefix):] + if num_part.isdigit(): + used.add(int(num_part)) + return max(used) + 1 if used else 1 + + +# ==================== 主流程 ==================== +def main(): + parser = argparse.ArgumentParser( + description="safety-eval-service 一键发布到开发环境 K8s", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--jar", default=None, help="本地 JAR 路径 (不指定则自动查找)") + parser.add_argument("--build", action="store_true", help="发布前先执行本地 mvn 编译") + parser.add_argument("--version", default=None, help="镜像版本序号 (默认自动递增)") + parser.add_argument("--app", default=APP_NAME, help=f"应用名 (默认: {APP_NAME})") + parser.add_argument("--prefix", default=ENV_PREFIX, help=f"环境前缀 (默认: {ENV_PREFIX})") + parser.add_argument("--no-deploy", action="store_true", help="只构建推送镜像,不部署 K8s") + parser.add_argument("--deploy-only", action="store_true", + help="只更新 K8s (需配合 --image 指定已存在的镜像)") + parser.add_argument("--image", default=None, help="指定完整镜像地址 (配合 --deploy-only)") + parser.add_argument("--restart", action="store_true", + help="只重启 K8s (不改镜像,等同 rollout restart)") + parser.add_argument("--dry-run", action="store_true", help="只打印计划,不实际执行") + args = parser.parse_args() + + global APP_NAME, ENV_PREFIX + APP_NAME = args.app + ENV_PREFIX = args.prefix + + # ---- 仅重启 ---- + if args.restart: + do_k8s_restart(args.dry_run) + return + + # ---- 仅部署 (镜像已存在) ---- + if args.deploy_only: + if not args.image: + print("ERROR: --deploy-only 需要配合 --image <完整镜像地址>") + sys.exit(1) + do_k8s_deploy(args.image, args.dry_run) + return + + # ==================== 完整流程: 编译+构建+推送+部署 ==================== + date_tag = datetime.now().strftime("%Y%m%d") + + # [0] 本地编译 (可选) + if args.build: + local_mvn_build(PROJECT_DIR) + + # [1] 定位 JAR + jar_local = args.jar or find_local_jar(PROJECT_DIR) + if not jar_local or not os.path.exists(jar_local): + print(f"ERROR: 未找到 JAR 文件: {jar_local or '(未自动发现)'}") + print(f" 可用 --jar 指定路径,或加 --build 自动编译。") + sys.exit(1) + jar_size_mb = os.path.getsize(jar_local) / 1024 / 1024 + jar_name = os.path.basename(jar_local) + + # ---- 连接 master 以确定版本号 ---- + print("\n连接 K8s Master 节点...") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(MASTER, username=SSH_USER, password=SSH_PASSWD, timeout=30) + print(f" SSH 已连接: {MASTER}") + + version_suffix = args.version or next_version_suffix(client) + image_tag = f"{ENV_PREFIX}-{date_tag}-{version_suffix}" + full_image = (f"{ACR_REGISTRY}/{ACR_NAMESPACE}/" + f"prod-aly-{ENV_PREFIX}-dragon-{APP_NAME}:{image_tag}") + + # ---- 打印发布计划 ---- + print("\n" + "=" * 66) + print(" safety-eval-service 一键发布到开发环境") + print("=" * 66) + print(f" JAR: {jar_local} ({jar_size_mb:.1f} MB)") + print(f" 应用: {APP_NAME}") + print(f" 镜像 Tag: {image_tag}") + print(f" 完整镜像: {full_image}") + print(f" K8s: {K8S_NAMESPACE}/{DEPLOY_NAME}") + print(f" Master: {MASTER}") + print("=" * 66) + + if args.dry_run: + print("\n[DRY RUN] 以上为发布计划,未实际执行。") + client.close() + return + + # [2] 准备远程目录 + 上传 JAR + print(f"\n[1/6] 上传 JAR 到 master ({jar_size_mb:.1f} MB)...") + ssh_exec(client, f"rm -rf {REMOTE_BUILD_DIR} && mkdir -p {REMOTE_BUILD_DIR}/target", + quiet=True) + sftp = client.open_sftp() + remote_jar = f"{REMOTE_BUILD_DIR}/target/{jar_name}" + upload_with_progress(sftp, jar_local, remote_jar) + + # 上传 Dockerfile + print(" 上传 Dockerfile...") + if os.path.exists(DOCKERFILE_PATH): + sftp.put(DOCKERFILE_PATH, f"{REMOTE_BUILD_DIR}/Dockerfile") + else: + # 回退: 用 heredoc 写入标准模板 + print(" (未找到本地 Dockerfile,使用 master 上的模板)") + ssh_exec(client, + f"cp /opt/docker-templates/Dockerfile {REMOTE_BUILD_DIR}/Dockerfile " + f"2>/dev/null || true", quiet=True) + sftp.close() + + # [3] 准备构建上下文 (复制 JDK) + print("\n[2/6] 准备构建上下文 (复制 JDK)...") + ssh_exec(client, f"cp -r /opt/jdk1.8.0_202 {REMOTE_BUILD_DIR}/", timeout=120) + ssh_exec(client, f"du -sh {REMOTE_BUILD_DIR}/") + + # [4] Docker build + print("\n[3/6] 构建 Docker 镜像...") + out, err, code = ssh_exec( + client, f"docker build -t '{full_image}' {REMOTE_BUILD_DIR}", timeout=600 + ) + if code != 0: + print("\nERROR: Docker build 失败!") + client.close() + sys.exit(1) + + # [5] 登录 + 推送 ACR + print("\n[4/6] 推送镜像到杭州 ACR...") + ssh_exec(client, + f"echo '{ACR_PASS}' | docker login --username={ACR_USER} " + f"--password-stdin {ACR_REGISTRY}", quiet=True) + out, err, code = ssh_exec(client, f"docker push '{full_image}'", timeout=900) + if code != 0: + print("\nERROR: Docker push 失败!") + client.close() + sys.exit(1) + ssh_exec(client, f"docker images | grep '{APP_NAME}' | head -5") + + # 清理远程构建目录 + ssh_exec(client, f"rm -rf {REMOTE_BUILD_DIR}", quiet=True) + + if args.no_deploy: + client.close() + print("\n" + "=" * 66) + print(" 构建推送完成 (--no-deploy 已跳过 K8s 部署)") + print("=" * 66) + print(f"\n 镜像地址: {full_image}\n") + return + + # [6] 部署到 K8s + print("\n[5/6] 部署到 K8s...") + do_k8s_deploy(full_image, dry_run=False, client=client) + + # [7] 校验 + print("\n[6/6] 校验 Pod 状态...") + verify_rollout(client) + + client.close() + print("\n" + "=" * 66) + print(" 发布完成!") + print("=" * 66) + print(f" 镜像: {full_image}") + print(f" 服务: safety-eval-service (Nacos: {K8S_NAMESPACE})") + print(f" 网关: http://192.168.20.100:30140/safety-eval/ (Header: filterGatewaName: cqanquan)") + print("=" * 66) + + +def do_k8s_deploy(full_image, dry_run, client=None): + """更新 K8s Deployment 镜像并触发滚动更新。""" + own_client = client is None + if own_client: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(MASTER, username=SSH_USER, password=SSH_PASSWD, timeout=30) + + # 检查 Deployment 是否存在 + out, _, code = ssh_exec( + client, + f"{KUBECTL} get deployment {DEPLOY_NAME} -n {K8S_NAMESPACE} --ignore-not-found", + quiet=True, + ) + deploy_exists = code == 0 and DEPLOY_NAME in out + + if dry_run: + print(f" [DRY RUN] 将更新镜像: {full_image}") + if own_client: + client.close() + return + + if deploy_exists: + # 更新镜像 + print(f" 更新 Deployment 镜像...") + out, err, code = ssh_exec( + client, + f"{KUBECTL} set image deployment/{DEPLOY_NAME} " + f"{CONTAINER_NAME}={full_image} -n {K8S_NAMESPACE}" + ) + if code != 0: + print(f" ERROR: kubectl set image 失败: {err}") + if own_client: + client.close() + sys.exit(1) + else: + # 首次部署: 用 docs/deploy 下的 yaml 创建 + print(f" Deployment 不存在,尝试 apply 部署 yaml...") + deploy_yaml = f"/tmp/{DEPLOY_NAME}.yaml" + local_yaml = os.path.join(PROJECT_DIR, "docs", "deploy", "safety-eval-deploy.yaml") + if os.path.exists(local_yaml): + sftp = client.open_sftp() + sftp.put(local_yaml, deploy_yaml) + sftp.close() + # 先替换镜像为本次构建的镜像 + ssh_exec(client, + f"sed -i 's|image: .*|image: {full_image}|' {deploy_yaml}", + quiet=True) + ssh_exec(client, f"{KUBECTL} apply -f {deploy_yaml} -n {K8S_NAMESPACE}") + ssh_exec(client, f"rm -f {deploy_yaml}", quiet=True) + else: + print(" ERROR: Deployment 不存在且找不到部署 yaml,请先手动创建 Deployment。") + if own_client: + client.close() + sys.exit(1) + + if own_client: + client.close() + + +def do_k8s_restart(dry_run): + """仅重启 K8s (不改镜像)。""" + print("=" * 66) + print(f" 重启 K8s Deployment: {K8S_NAMESPACE}/{DEPLOY_NAME}") + print("=" * 66) + if dry_run: + print(" [DRY RUN] 将执行 rollout restart") + return + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(MASTER, username=SSH_USER, password=SSH_PASSWD, timeout=30) + print("\n触发滚动重启...") + ssh_exec(client, f"{KUBECTL} rollout restart deployment/{DEPLOY_NAME} -n {K8S_NAMESPACE}") + verify_rollout(client) + client.close() + + +def verify_rollout(client): + """等待滚动更新完成并打印 Pod 状态与最近日志。""" + print(" 等待滚动更新完成 (最多 300s)...") + out, err, code = ssh_exec( + client, + f"{KUBECTL} rollout status deployment/{DEPLOY_NAME} " + f"-n {K8S_NAMESPACE} --timeout=300s", + timeout=320, + ) + if code != 0: + print(f" ⚠ 滚动更新未完成或超时: {err}") + print(" 最近事件:") + ssh_exec(client, + f"{KUBECTL} get events -n {K8S_NAMESPACE} --sort-by=.metadata.creationTimestamp " + f"| grep -i 'safety-eval' | tail -15") + + # Pod 状态 + print("\n Pod 状态:") + ssh_exec(client, f"{KUBECTL} get pods -n {K8S_NAMESPACE} -o wide | grep 'safety-eval' | head -10") + + # 最近日志 + print("\n 最近启动日志 (最后 40 行):") + ssh_exec(client, + f"{KUBECTL} logs deployment/{DEPLOY_NAME} -n {K8S_NAMESPACE} --tail=40") + + +if __name__ == "__main__": + main()