#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ soul-api 一键部署到宝塔【测试环境】 打包原则:优先使用本地已有资源,不边打包边下载(省时)。 - 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像) - 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存 两种模式均部署到测试环境 /www/wwwroot/self/soul-dev: - binary:Go 二进制 + 宝塔 soulDev 项目,用 .env.development - docker(默认):本地 go build → Dockerfile.local 打镜像 → 蓝绿无缝切换 - 镜像内包含:二进制、soul-api/certs/ → /app/certs/、选定环境文件 → /app/.env - 环境文件:--env-file 或 DOCKER_ENV_FILE,否则自动 .env.development > .env.production > .env(.dockerignore 已放行) - 不加 --docker-in-go 时:本地 Go 编译 + 本地 base 镜像,不联网 - 加 --docker-in-go 时:在 Docker 内用 golang 镜像编译(需本地已有 golang:1.25) 环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST 等 """ from __future__ import print_function import hashlib import os import sys import tempfile import argparse import subprocess import shutil import tarfile import time try: import paramiko except ImportError: print("错误: 请先安装 paramiko") print(" pip install paramiko") sys.exit(1) try: import requests try: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except Exception: pass except ImportError: requests = None # ==================== 配置 ==================== DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev" DEPLOY_DOCKER_PATH = os.environ.get("DEPLOY_DOCKER_PATH", "/www/wwwroot/self/soul-dev") DEPLOY_NGINX_CONF = os.environ.get("DEPLOY_NGINX_CONF", "") # 如 /www/server/panel/vhost/nginx/soulapi.quwanzhi.com.conf DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022")) # 宝塔 API 密钥(写死,用于部署后重启 Go 项目) BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT" def get_cfg(): host = os.environ.get("DEPLOY_HOST", "43.139.27.93") bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/") if not bt_url: bt_url = "https://%s:9988" % host return { "host": host, "user": os.environ.get("DEPLOY_USER", "root"), "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH), "bt_panel_url": bt_url, "bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT), "bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"), } # ==================== 本地构建 ==================== def run_build(root): """交叉编译 Go 二进制(Linux amd64)""" print("[1/4] 本地交叉编译 Go 二进制 ...") env = os.environ.copy() env["GOOS"] = "linux" env["GOARCH"] = "amd64" env["CGO_ENABLED"] = "0" cmd = ["go", "build", "-o", "soul-api", "./cmd/server"] try: r = subprocess.run( cmd, cwd=root, env=env, shell=False, timeout=120, capture_output=True, text=True, encoding="utf-8", errors="replace", ) if r.returncode != 0: print(" [失败] go build 失败,退出码:", r.returncode) if r.stderr: for line in (r.stderr or "").strip().split("\n")[-10:]: print(" " + line) return None out_path = os.path.join(root, "soul-api") if not os.path.isfile(out_path): print(" [失败] 未找到编译产物 soul-api") return None print(" [成功] 编译完成: %s (%.2f MB)" % (out_path, os.path.getsize(out_path) / 1024 / 1024)) return out_path except subprocess.TimeoutExpired: print(" [失败] 编译超时") return None except FileNotFoundError: print(" [失败] 未找到 go 命令,请安装 Go") return None except Exception as e: print(" [失败] 编译异常:", str(e)) return None # ==================== 打包 ==================== DEPLOY_PORT = 9001 def set_env_port(env_path, port=DEPLOY_PORT): """将 .env 文件中的 PORT 设为指定值(用于部署包)""" if not os.path.isfile(env_path): return with open(env_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() found = False new_lines = [] for line in lines: s = line.strip() if "=" in s and s.split("=", 1)[0].strip() == "PORT": new_lines.append("PORT=%s\n" % port) found = True else: new_lines.append(line) if not found: new_lines.append("PORT=%s\n" % port) with open(env_path, "w", encoding="utf-8", newline="\n") as f: f.writelines(new_lines) def set_env_mini_program_state(env_path, state): """将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal""" if not os.path.isfile(env_path): return key = "WECHAT_MINI_PROGRAM_STATE" with open(env_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() found = False new_lines = [] for line in lines: s = line.strip() if "=" in s and s.split("=", 1)[0].strip() == key: new_lines.append("%s=%s\n" % (key, state)) found = True else: new_lines.append(line) if not found: new_lines.append("%s=%s\n" % (key, state)) with open(env_path, "w", encoding="utf-8", newline="\n") as f: f.writelines(new_lines) def resolve_binary_pack_env_src(root): """binary 模式 tar 包内 .env 的来源,与 Docker 自动优先级一致。""" for name in (".env.development", ".env.production", ".env"): p = os.path.join(root, name) if os.path.isfile(p): return p, name return None, None def pack_deploy(root, binary_path, include_env=True): """打包二进制和 .env 为 tar.gz""" print("[2/4] 打包部署文件 ...") staging = tempfile.mkdtemp(prefix="soul_api_deploy_") try: shutil.copy2(binary_path, os.path.join(staging, "soul-api")) staging_env = os.path.join(staging, ".env") if include_env: env_src, env_label = resolve_binary_pack_env_src(root) if env_src: shutil.copy2(env_src, staging_env) print(" [已包含] %s -> .env" % env_label) else: env_example = os.path.join(root, ".env.example") if os.path.isfile(env_example): shutil.copy2(env_example, staging_env) print(" [已包含] .env.example -> .env (请服务器上检查配置)") if os.path.isfile(staging_env): set_env_port(staging_env, DEPLOY_PORT) set_env_mini_program_state(staging_env, "developer") print(" [已设置] PORT=%s, WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT) tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz") with tarfile.open(tarball, "w:gz") as tf: for name in os.listdir(staging): tf.add(os.path.join(staging, name), arcname=name) print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024)) return tarball except Exception as e: print(" [失败] 打包异常:", str(e)) return None finally: shutil.rmtree(staging, ignore_errors=True) # ==================== 宝塔 API 重启 ==================== def restart_via_bt_api(cfg): """通过宝塔 API 重启 Go 项目""" url = cfg.get("bt_panel_url") or "" key = cfg.get("bt_api_key") or "" name = cfg.get("bt_go_project_name", "soulDev") if not url or not key: return False if not requests: print(" [提示] 未安装 requests,pip install requests") return False try: req_time = int(time.time()) sk_md5 = hashlib.md5(key.encode()).hexdigest() req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() base = url.rstrip("/") params = {"request_time": req_time, "request_token": req_token} for action in ("stop_go_project", "start_go_project"): data = dict(params) data["action"] = action data["project_name"] = name r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False) if r.status_code != 200: continue j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} if action == "stop_go_project": time.sleep(2) if j.get("status") is False and j.get("msg"): print(" [宝塔API] %s: %s" % (action, j.get("msg", ""))) data = dict(params) data["action"] = "start_go_project" data["project_name"] = name r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False) if r.status_code == 200: j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} if j.get("status") is True: print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name) return True return False except Exception as e: print(" [宝塔API 失败] %s" % str(e)) return False # ==================== SSH 上传 ==================== def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"): """上传 tar.gz 到服务器并解压、重启""" print("[3/4] SSH 上传并解压 ...") if not cfg.get("password") and not cfg.get("ssh_key"): print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") return False client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15) else: client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15) sftp = client.open_sftp() remote_tar = "/tmp/soul_api_deploy.tar.gz" project_path = cfg["project_path"] sftp.put(tarball_path, remote_tar) sftp.close() cmd = "mkdir -p %s && cd %s && tar -xzf %s && chmod +x soul-api && rm -f %s && echo OK" % (project_path, project_path, remote_tar, remote_tar) stdin, stdout, stderr = client.exec_command(cmd, timeout=60) out = stdout.read().decode("utf-8", errors="replace").strip() exit_status = stdout.channel.recv_exit_status() if exit_status != 0 or "OK" not in out: print(" [失败] 解压失败,退出码:", exit_status) return False print(" [成功] 已解压到: %s" % project_path) if not no_restart: print("[4/4] 重启 soulDev 服务 ...") ok = False if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")): ok = restart_via_bt_api(cfg) if not ok and restart_method in ("auto", "ssh"): restart_cmd = ( "cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do " '[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && kill $p 2>/dev/null; done; ' "sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null); do " '[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL' ) % project_path stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20) out = stdout.read().decode("utf-8", errors="replace").strip() err = (stderr.read().decode("utf-8", errors="replace") or "").strip() if err: print(" [stderr] %s" % err[:200]) ok = "RESTART_OK" in out if ok: print(" [成功] soulDev 已通过 SSH 重启") else: print(" [警告] 请手动启动: cd %s && ./soul-api" % project_path) else: print("[4/4] 跳过重启 (--no-restart)") return True except Exception as e: print(" [失败] SSH 错误:", str(e)) return False finally: client.close() # ==================== 宝塔 API - Nginx 配置与重载 ==================== def _bt_request(cfg, endpoint, data): """宝塔 API 通用请求(request_time + request_token 签名)""" if not requests: return None, None url = (cfg.get("bt_panel_url") or "").rstrip("/") key = cfg.get("bt_api_key") or "" if not url or not key: return None, None try: req_time = int(time.time()) sk_md5 = hashlib.md5(key.encode()).hexdigest() req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() payload = dict(data) payload["request_time"] = req_time payload["request_token"] = req_token r = requests.post(url + endpoint, data=payload, timeout=15, verify=False) if r.status_code != 200: return None, r j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} return j, r except Exception as e: print(" [宝塔API] 请求异常:", str(e)) return None, None def deploy_nginx_via_bt_api(cfg, nginx_conf_path, new_port): """ 通过宝塔 API 更新 Nginx 配置并重载。 - 使用 files.GetFileBody 读取配置 - 替换 proxy_pass 端口 - 使用 files.SaveFileBody 保存 - 尝试 service 插件重载 nginx """ if not nginx_conf_path or not new_port: return False if not requests: print(" [提示] 未安装 requests,无法使用宝塔 Nginx API。pip install requests") return False # 1. 读取配置 j, _ = _bt_request(cfg, "/files?action=GetFileBody", {"path": nginx_conf_path}) if not j or "status" in j and j.get("status") is False: print(" [宝塔API] 读取 Nginx 配置失败:", j.get("msg", "未知错误") if j else "") return False content = j.get("data") or j.get("content") or "" if isinstance(content, bytes): content = content.decode("utf-8", errors="replace") # 2. 替换 proxy_pass 端口 import re new_content = re.sub( r"proxy_pass\s+http://127\.0\.0\.1:\d+", "proxy_pass http://127.0.0.1:%s" % new_port, content, flags=re.IGNORECASE, ) if new_content == content: print(" [宝塔API] 未找到 proxy_pass,可能已是目标端口或格式不符") # 3. 保存配置 j, _ = _bt_request(cfg, "/files?action=SaveFileBody", { "path": nginx_conf_path, "data": new_content, "encoding": "utf-8", }) if not j or "status" in j and j.get("status") is False: print(" [宝塔API] 保存 Nginx 配置失败:", j.get("msg", "未知错误") if j else "") return False # 4. 重载 Nginx(尝试 service 插件) for try_action, try_name in [ ("reload", "nginx"), ("RestartService", "nginx"), ]: j, _ = _bt_request(cfg, "/service?action=%s" % try_action, {"name": try_name}) if j and j.get("status") is True: print(" [成功] 已通过宝塔 API 重载 Nginx (端口 %s)" % new_port) return True # 部分面板无 service 接口,配置已保存,需手动重载 print(" [提示] Nginx 配置已通过宝塔 API 更新,重载请到面板操作或使用 SSH: nginx -s reload") return True # ==================== Docker 部署(蓝绿无缝切换) ==================== def resolve_docker_env_file(root, explicit=None): """ 选择打入镜像的环境文件(相对 soul-api 根目录,须能被 Docker 构建上下文包含)。 Dockerfile: COPY ${ENV_FILE} /app/.env;certs/ 由 COPY certs/ 一并打入。 优先级:explicit → DOCKER_ENV_FILE → 自动 .env.development > .env.production > .env(与测试环境默认一致) """ if explicit: name = os.path.basename(explicit.replace("\\", "/")) path = os.path.join(root, name) if os.path.isfile(path): print(" [镜像配置] 打入镜像的环境文件: %s(--env-file)" % name) return name print(" [失败] --env-file 不存在: %s" % path) return None override = (os.environ.get("DOCKER_ENV_FILE") or "").strip() if override: name = os.path.basename(override.replace("\\", "/")) path = os.path.join(root, name) if os.path.isfile(path): print(" [镜像配置] 打入镜像的环境文件: %s(DOCKER_ENV_FILE)" % name) return name print(" [失败] DOCKER_ENV_FILE 指向的文件不存在: %s" % path) return None for name in (".env.development", ".env.production", ".env"): if os.path.isfile(os.path.join(root, name)): print(" [镜像配置] 打入镜像的环境文件: %s(自动选择)" % name) return name print(" [失败] 未找到 .env.development / .env.production / .env,无法 COPY 进镜像") return None def run_docker_build(root, env_file=".env.development"): """本地构建 Docker 镜像(使用 Docker 内的 golang 镜像)""" print("[1/5] 构建 Docker 镜像 ...(进度见下方 Docker 输出)") try: cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile", "-t", "soul-api:latest", "--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."] r = subprocess.run(cmd, cwd=root, shell=False, timeout=300) if r.returncode != 0: print(" [失败] docker build 失败,退出码:", r.returncode) return None print(" [成功] 镜像构建完成 soul-api:latest") return True except FileNotFoundError: print(" [失败] 未找到 docker 命令,请安装 Docker") return None except subprocess.TimeoutExpired: print(" [失败] 构建超时") return None except Exception as e: print(" [失败] 构建异常:", str(e)) return None def run_docker_build_local(root, env_file=".env.development"): """使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像,--pull=false 不拉 base 镜像)""" print("[1/5] 使用本地 Go 交叉编译 ...") binary_path = run_build(root) if not binary_path: return None print("[2/5] 使用 Dockerfile.local 构建镜像 ...(--pull=false 仅用本地缓存)") try: cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest", "--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."] r = subprocess.run(cmd, cwd=root, shell=False, timeout=120) if r.returncode != 0: print(" [失败] docker build 失败,退出码:", r.returncode) return None print(" [成功] 镜像构建完成 soul-api:latest(本地 Go 参与构建)") return True except FileNotFoundError: print(" [失败] 未找到 docker 命令,请安装 Docker") return None except subprocess.TimeoutExpired: print(" [失败] 构建超时") return None except Exception as e: print(" [失败] 构建异常:", str(e)) return None def pack_docker_image(root): """将 soul-api 与 redis 镜像一并导出为 tar.gz,服务器无需拉取""" import gzip print("[3/5] 导出镜像为 tar.gz(soul-api + redis)...") out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz") try: r = subprocess.run( ["docker", "save", "soul-api:latest", "docker.m.daocloud.io/library/redis:7-alpine"], capture_output=True, timeout=180, cwd=root, ) if r.returncode != 0: stderr = (r.stderr or b"").decode("utf-8", errors="replace")[:300] print(" [失败] docker save 失败:", stderr) print(" [提示] 请确保本地有 redis 镜像,执行: docker images | findstr redis 查看名称") return None with gzip.open(out_tar, "wb") as f: f.write(r.stdout) if not os.path.isfile(out_tar) or os.path.getsize(out_tar) < 1000: print(" [失败] 导出文件异常") return None print(" [成功] 导出完成: %.2f MB(soul-api + redis)" % (os.path.getsize(out_tar) / 1024 / 1024)) return out_tar except subprocess.TimeoutExpired: print(" [失败] docker save 超时") return None except Exception as e: print(" [失败] 导出异常:", str(e)) return None def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_method="ssh"): """上传镜像与配置到服务器,执行蓝绿部署。deploy_method: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx""" deploy_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH) nginx_conf = os.environ.get("DEPLOY_NGINX_CONF", DEPLOY_NGINX_CONF) script_dir = os.path.dirname(os.path.abspath(__file__)) print("[4/5] SSH 上传镜像与配置 ...") if not cfg.get("password") and not cfg.get("ssh_key"): print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") return False client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30) else: client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30) sftp = client.open_sftp() remote_tar = "/tmp/soul_api_image.tar.gz" sftp.put(image_tar_path, remote_tar) print(" [已上传] 镜像 tar.gz") compose_local = os.path.join(script_dir, "deploy", "docker-compose.bluegreen.yml") deploy_local = os.path.join(script_dir, "deploy", "docker-deploy-remote.sh") env_local = os.path.join(script_dir, ".env.production") if os.path.isfile(compose_local): sftp.put(compose_local, deploy_path + "/docker-compose.bluegreen.yml") print(" [已上传] docker-compose.bluegreen.yml") if os.path.isfile(deploy_local): sftp.put(deploy_local, deploy_path + "/docker-deploy-remote.sh") print(" [已上传] docker-deploy-remote.sh") # 注意:docker-compose.bluegreen.yml 未配置 env_file,容器实际以镜像内 /app/.env 为准; # 此处上传仅供服务器目录备份或手工改 compose 后使用。 if include_env and os.path.isfile(env_local): sftp.put(env_local, deploy_path.rstrip("/") + "/.env") print(" [已上传] .env.production -> 服务器 %s/.env(可选;默认不挂载进容器)" % deploy_path.rstrip("/")) # btapi 模式:需先读取 .active 计算新端口,脚本内跳过 Nginx current_active = "blue" if deploy_method == "btapi" and nginx_conf: try: active_file = deploy_path.rstrip("/") + "/.active" with sftp.open(active_file, "r") as f: current_active = (f.read().decode("utf-8", errors="replace") or "blue").strip() or "blue" except Exception: pass new_port = 8082 if current_active == "blue" else 8081 sftp.close() print("[5/5] 执行蓝绿部署 ...") env_exports = "" if nginx_conf: env_exports += "export DEPLOY_NGINX_CONF='%s'; " % nginx_conf.replace("'", "'\\''") env_exports += "export DEPLOY_DOCKER_PATH='%s'; " % deploy_path.replace("'", "'\\''") script_args = remote_tar if deploy_method == "btapi" and nginx_conf: script_args += " --skip-nginx" cmd = "mkdir -p %s && %s cd %s && chmod +x docker-deploy-remote.sh && ./docker-deploy-remote.sh %s" % (deploy_path, env_exports, deploy_path, script_args) stdin, stdout, stderr = client.exec_command(cmd, timeout=180) out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") exit_status = stdout.channel.recv_exit_status() print(out) if err: print(err[:500]) if exit_status != 0: print(" [失败] 远程部署脚本退出码:", exit_status) return False # btapi 模式:通过宝塔 API 更新 Nginx 配置并重载(new_port 已在上方计算) if deploy_method == "btapi" and nginx_conf: try: print(" [宝塔 API] 更新 Nginx ...") deploy_nginx_via_bt_api(cfg, nginx_conf, new_port) except Exception as e: print(" [警告] 宝塔 Nginx API 失败:", str(e)) print(" 部署完成,蓝绿无缝切换") return True except Exception as e: print(" [失败] SSH 错误:", str(e)) return False finally: client.close() # ==================== 主函数 ==================== def main(): parser = argparse.ArgumentParser(description="soul-api 测试环境一键部署到宝塔") parser.add_argument("--mode", choices=("binary", "docker"), default="docker", help="docker=Docker 蓝绿部署 (默认), binary=Go 二进制") parser.add_argument("--no-build", action="store_true", help="跳过本地编译/构建") parser.add_argument("--no-env", action="store_true", help="binary: 不打进 tar;docker: 不上传服务器目录 .env.production(镜像内配置不变)") parser.add_argument("--no-restart", action="store_true", help="[binary] 上传后不重启") parser.add_argument("--restart-method", choices=("auto", "btapi", "ssh"), default="auto", help="[binary] 重启方式: auto/btapi/ssh") parser.add_argument("--docker-in-go", action="store_true", help="[docker] 在 Docker 内用 golang 镜像编译(默认:本地 go build → 再打镜像)") parser.add_argument("--deploy-method", choices=("ssh", "btapi"), default="ssh", help="[docker] 部署方式: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx 配置并重载 (默认 ssh)") parser.add_argument("--env-file", default=None, metavar="NAME", help="[docker] 打入镜像的环境文件名(默认自动:.env.development > .env.production > .env)") args = parser.parse_args() script_dir = os.path.dirname(os.path.abspath(__file__)) root = script_dir cfg = get_cfg() if args.mode == "docker": docker_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH) print("=" * 60) print(" soul-api 测试环境 Docker 蓝绿部署(无缝切换)") print("=" * 60) print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT)) print(" 目标目录: %s" % docker_path) print("=" * 60) if not args.no_build: env_for_image = resolve_docker_env_file(root, explicit=args.env_file) if env_for_image is None: return 1 # 默认:本地 go build → Dockerfile.local 打镜像;--docker-in-go 时在容器内编译 ok = ( run_docker_build(root, env_file=env_for_image) if args.docker_in_go else run_docker_build_local(root, env_file=env_for_image) ) if not ok: return 1 else: print("[1/5] 跳过构建,使用现有 soul-api:latest(无需本地环境文件)") image_tar = pack_docker_image(root) if not image_tar: return 1 if not upload_and_deploy_docker(cfg, image_tar, include_env=not args.no_env, deploy_method=args.deploy_method): return 1 try: os.remove(image_tar) except Exception: pass print("") print(" 部署完成!测试环境蓝绿无缝切换") return 0 # ===== Binary 模式 ===== print("=" * 60) print(" soul-api 测试环境 部署到宝塔,重启 soulDev") print("=" * 60) print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT)) print(" 目标目录: %s" % cfg["project_path"]) print("=" * 60) binary_path = os.path.join(root, "soul-api") if not args.no_build: p = run_build(root) if not p: return 1 else: if not os.path.isfile(binary_path): print("[错误] 未找到 soul-api 二进制") return 1 print("[1/4] 跳过编译") tarball = pack_deploy(root, binary_path, include_env=not args.no_env) if not tarball: return 1 if not upload_and_extract(cfg, tarball, no_restart=args.no_restart, restart_method=args.restart_method): return 1 try: os.remove(tarball) except Exception: pass print("") print(" 部署完成!目录: %s" % cfg["project_path"]) return 0 if __name__ == "__main__": sys.exit(main())