996 lines
40 KiB
Python
996 lines
40 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
soul-api 一键部署到宝塔【测试环境】
|
||
|
||
打包原则:优先使用本地已有资源,不边打包边下载(省时)。
|
||
- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
|
||
- 首次部署前请本地先拉好:alpine:3.19、redis:7-alpine,后续一律用本地缓存
|
||
|
||
三种模式:
|
||
|
||
- runner(推荐):容器内红蓝切换,宝塔固定 proxy_pass 到 9001,无需改配置
|
||
- 使用 network_mode: host,避免 iptables 端口映射问题
|
||
- 首次:服务器执行 deploy/runner-init.sh 构建并启动容器
|
||
- 部署:python devloy.py --mode runner
|
||
|
||
- docker(默认):本地 go build → Dockerfile.local 打镜像 → 宿主机蓝绿切换
|
||
- 需宿主机改 Nginx proxy_pass 或宝塔 API
|
||
|
||
- binary:Go 二进制 + 宝塔 soulDev 项目,用 .env.development
|
||
|
||
环境变量:DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST、DEPLOY_RUNNER_CONTAINER 等
|
||
"""
|
||
|
||
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
|
||
deploy_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH).rstrip("/")
|
||
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).rstrip("/"),
|
||
"deploy_path": deploy_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 set_env_key(env_path, key, value):
|
||
"""将 .env 中指定 key 设为 value"""
|
||
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() == key:
|
||
new_lines.append("%s=%s\n" % (key, value))
|
||
found = True
|
||
else:
|
||
new_lines.append(line)
|
||
if not found:
|
||
new_lines.append("%s=%s\n" % (key, value))
|
||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||
f.writelines(new_lines)
|
||
|
||
|
||
def set_env_redis_url(env_path, url):
|
||
"""将 .env 中的 REDIS_URL 设为指定值"""
|
||
if not os.path.isfile(env_path):
|
||
return
|
||
key = "REDIS_URL"
|
||
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, url))
|
||
found = True
|
||
else:
|
||
new_lines.append(line)
|
||
if not found:
|
||
new_lines.append("%s=%s\n" % (key, url))
|
||
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_runner_deploy(root, binary_path, include_env=True):
|
||
"""打包 Runner 部署包:二进制 + .env + certs,供容器内红蓝切换"""
|
||
print("[2/4] 打包 Runner 部署包 ...")
|
||
staging = tempfile.mkdtemp(prefix="soul_api_runner_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, 18081)
|
||
set_env_redis_url(staging_env, "redis://:soul-docker-redis@127.0.0.1:6379/0")
|
||
set_env_mini_program_state(staging_env, "developer")
|
||
set_env_key(staging_env, "UPLOAD_DIR", "/app/uploads")
|
||
print(" [已设置] PORT=18081, REDIS_URL, UPLOAD_DIR=/app/uploads, WECHAT_MINI_PROGRAM_STATE=developer")
|
||
certs_src = os.path.join(root, "certs")
|
||
if os.path.isdir(certs_src):
|
||
certs_dst = os.path.join(staging, "certs")
|
||
os.makedirs(certs_dst, exist_ok=True)
|
||
for f in os.listdir(certs_src):
|
||
src = os.path.join(certs_src, f)
|
||
if os.path.isfile(src):
|
||
shutil.copy2(src, os.path.join(certs_dst, f))
|
||
print(" [已包含] certs/")
|
||
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):
|
||
p = os.path.join(staging, name)
|
||
tf.add(p, 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)
|
||
|
||
|
||
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()
|
||
project_path = cfg["project_path"]
|
||
remote_tar = project_path + "/soul_api_deploy.tar.gz"
|
||
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/.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 镜像为 tar.gz(线上 Redis 已在运行,不再打包/加载)"""
|
||
import gzip
|
||
print("[3/5] 导出镜像为 tar.gz(soul-api only)...")
|
||
out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz")
|
||
try:
|
||
r = subprocess.run(
|
||
["docker", "save", "soul-api:latest"],
|
||
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 only)" % (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 = cfg.get("deploy_path") or os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
|
||
deploy_path = deploy_path.rstrip("/")
|
||
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 = deploy_path + "/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 = 9002 if current_active == "blue" else 9001
|
||
|
||
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()
|
||
|
||
|
||
# ==================== Runner 部署(容器内红蓝切换) ====================
|
||
|
||
RUNNER_CONTAINER = os.environ.get("DEPLOY_RUNNER_CONTAINER", "soul-api-runner")
|
||
CHUNK_SIZE = 65536
|
||
|
||
|
||
def _ssh_connect(cfg, timeout=30):
|
||
"""建立 SSH 连接"""
|
||
client = paramiko.SSHClient()
|
||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||
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=timeout)
|
||
else:
|
||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=timeout)
|
||
return client
|
||
|
||
|
||
def deploy_runner_container(cfg):
|
||
"""推送 Runner 容器到服务器:本地构建镜像 → 上传 → docker load → compose up"""
|
||
print("=" * 60)
|
||
print(" soul-api Runner 容器推送(首次或更新容器)")
|
||
print("=" * 60)
|
||
deploy_path = cfg.get("deploy_path") or os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
|
||
deploy_path = deploy_path.rstrip("/")
|
||
root = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
print("[1/4] 本地构建 Runner 镜像 ...")
|
||
try:
|
||
r = subprocess.run(
|
||
["docker", "build", "-f", "deploy/Dockerfile.runner", "-t", "soul-api-runner:latest", "."],
|
||
cwd=root, shell=False, timeout=120, capture_output=True, text=True, encoding="utf-8", errors="replace"
|
||
)
|
||
if r.returncode != 0:
|
||
print(" [失败] docker build 失败:", (r.stderr or "")[-500:])
|
||
return False
|
||
except FileNotFoundError:
|
||
print(" [失败] 未找到 docker 命令")
|
||
return False
|
||
|
||
print("[2/4] 导出镜像为 tar.gz ...")
|
||
import gzip
|
||
img_tar = os.path.join(tempfile.gettempdir(), "soul_runner_image.tar.gz")
|
||
try:
|
||
r = subprocess.run(["docker", "save", "soul-api-runner:latest"], capture_output=True, timeout=180, cwd=root)
|
||
if r.returncode != 0:
|
||
print(" [失败] docker save 失败")
|
||
return False
|
||
with gzip.open(img_tar, "wb") as f:
|
||
f.write(r.stdout)
|
||
except Exception as e:
|
||
print(" [失败] 导出异常:", str(e))
|
||
return False
|
||
|
||
print("[3/4] SSH 上传镜像并加载 ...")
|
||
client = None
|
||
try:
|
||
client = _ssh_connect(cfg, timeout=60)
|
||
sftp = client.open_sftp()
|
||
remote_img = deploy_path + "/soul_runner_image.tar.gz"
|
||
sftp.put(img_tar, remote_img)
|
||
sftp.close()
|
||
os.remove(img_tar)
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
compose_standalone = os.path.join(script_dir, "deploy", "docker-compose.runner.standalone.yml")
|
||
sftp = client.open_sftp()
|
||
sftp.put(compose_standalone, deploy_path + "/docker-compose.runner.standalone.yml")
|
||
sftp.close()
|
||
cmd = (
|
||
"mkdir -p %s && cd %s && gunzip -c soul_runner_image.tar.gz | docker load && "
|
||
"docker-compose -f docker-compose.runner.standalone.yml up -d && "
|
||
"rm -f soul_runner_image.tar.gz && echo OK"
|
||
) % (deploy_path, deploy_path)
|
||
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||
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 or "OK" not in out:
|
||
print(" [失败] 远程执行退出码:", exit_status)
|
||
return False
|
||
except Exception as e:
|
||
print(" [失败] SSH 错误:", str(e))
|
||
return False
|
||
finally:
|
||
if client:
|
||
client.close()
|
||
|
||
print("[4/4] Runner 容器已启动")
|
||
print(" 宝塔 proxy_pass 保持 127.0.0.1:9001")
|
||
return True
|
||
|
||
|
||
def upload_and_deploy_runner(cfg, tarball_path):
|
||
"""将部署包通过 SSH 管道直接传入容器,宿主机不落盘,防止机密泄露"""
|
||
print("[3/4] 管道直传容器(宿主机不落盘)...")
|
||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||
return False
|
||
client = None
|
||
try:
|
||
client = _ssh_connect(cfg, timeout=60)
|
||
file_size = os.path.getsize(tarball_path)
|
||
print(" 传输 %.2f MB 到容器 /tmp/incoming.tar.gz ..." % (file_size / 1024 / 1024))
|
||
stdin, stdout, stderr = client.exec_command(
|
||
"docker exec -i %s sh -c 'cat > /tmp/incoming.tar.gz'" % RUNNER_CONTAINER,
|
||
timeout=300,
|
||
)
|
||
with open(tarball_path, "rb") as f:
|
||
while True:
|
||
chunk = f.read(CHUNK_SIZE)
|
||
if not chunk:
|
||
break
|
||
stdin.write(chunk)
|
||
stdin.channel.shutdown_write()
|
||
stdout.channel.recv_exit_status()
|
||
err = stderr.read().decode("utf-8", errors="replace")
|
||
if err and "Error" in err:
|
||
print(" [失败] 管道写入异常:", err[:300])
|
||
return False
|
||
print(" [已传入容器] 执行红蓝切换 ...")
|
||
stdin, stdout, stderr = client.exec_command(
|
||
"docker exec %s /app/deploy.sh /tmp/incoming.tar.gz" % RUNNER_CONTAINER,
|
||
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[:800])
|
||
if exit_status != 0:
|
||
print(" [失败] 远程部署退出码:", exit_status)
|
||
return False
|
||
return True
|
||
except Exception as e:
|
||
print(" [失败] SSH 错误: %s" % str(e))
|
||
return False
|
||
finally:
|
||
if client:
|
||
client.close()
|
||
|
||
|
||
# ==================== 主函数 ====================
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="soul-api 测试环境一键部署到宝塔")
|
||
parser.add_argument("--mode", choices=("binary", "docker", "runner", "start"), default="runner",
|
||
help="runner=仅上传代码(默认), start=容器+代码, 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)")
|
||
parser.add_argument("--init-runner", action="store_true",
|
||
help="[runner] 等同于 --mode start,先推送容器再部署代码")
|
||
args = parser.parse_args()
|
||
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
root = script_dir
|
||
cfg = get_cfg()
|
||
|
||
# start = 容器+代码;runner = 仅代码
|
||
init_runner = args.init_runner or (args.mode == "start")
|
||
if args.mode == "start":
|
||
args.mode = "runner"
|
||
|
||
if args.mode == "runner":
|
||
print("=" * 60)
|
||
print(" soul-api Runner 模式(容器内红蓝切换,宝塔固定 9001)")
|
||
print("=" * 60)
|
||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||
print(" 容器: %s" % RUNNER_CONTAINER)
|
||
print("=" * 60)
|
||
if init_runner:
|
||
if not deploy_runner_container(cfg):
|
||
return 1
|
||
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_runner_deploy(root, binary_path, include_env=not args.no_env)
|
||
if not tarball:
|
||
return 1
|
||
if not upload_and_deploy_runner(cfg, tarball):
|
||
return 1
|
||
try:
|
||
os.remove(tarball)
|
||
except Exception:
|
||
pass
|
||
print("")
|
||
print(" 部署完成!宝塔代理 9001 无需修改")
|
||
return 0
|
||
|
||
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())
|