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

996 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
- binaryGo 二进制 + 宝塔 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(" [提示] 未安装 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()
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/.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 镜像为 tar.gz线上 Redis 已在运行,不再打包/加载)"""
import gzip
print("[3/5] 导出镜像为 tar.gzsoul-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 MBsoul-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: 不打进 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")
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())