Files
soul-yongping/soul-api/devloy.py

717 lines
30 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-api 一键部署到宝塔测试环境
打包原则优先使用本地已有资源不边打包边下载省时
- 默认本地 go build Dockerfile.local 打镜像--pull=false 不拉 base 镜像
- 首次部署前请本地先拉好alpine:3.19redis:7-alpine后续一律用本地缓存
两种模式均部署到测试环境 /www/wwwroot/self/soul-dev
- binaryGo 二进制 + 宝塔 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_PATHDEPLOY_NGINX_CONFDEPLOY_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(" [提示] 未安装 requestspip 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 & "
"sleep 3; T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/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/.envcerts/ 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(" [镜像配置] 打入镜像的环境文件: %sDOCKER_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.gzsoul-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 MBsoul-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: 不打进 tardocker: 不上传服务器目录 .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())