437 lines
16 KiB
Python
437 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
from __future__ import print_function
|
||
|
||
"""
|
||
玩值 wz-api 一键部署(new/wz-api,宝塔面板 API)
|
||
目标目录默认: /www/wwwroot/self/wzdj-options/api
|
||
|
||
用法:
|
||
python master.py
|
||
python master.py --no-build --no-restart
|
||
python master.py --skip-bt-ping
|
||
|
||
环境变量: DEPLOY_HOST、BT_PANEL_URL、BT_API_KEY、DEPLOY_PROJECT_PATH、
|
||
BT_GO_PROJECT_NAME(默认 wzApi,与面板一致)、BT_GO_SITE_ID、DEPLOY_API_PORT(默认 4040)
|
||
|
||
Mongo 数据上云(本地 → 宝塔 wzdj,推荐走面板 API 上传+服务端 mongorestore)见 scripts/mongo_push_baota_api.py;
|
||
亦可用手动 mongorestore 见 scripts/MONGO_宝塔同步.md 与 scripts/mongo_sync_baota.py(非本脚本职责)。
|
||
Go 项目插件重启失败(如「指定参数无效」)时,会自动尝试宝塔 ExecShell 在项目目录内重启 wz-api;若不希望执行远端 Shell,可设 BT_NO_EXECSHELL_RESTART=1。
|
||
依赖: pip install requests
|
||
"""
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tarfile
|
||
import tempfile
|
||
import time
|
||
|
||
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/wzdj-options/api"
|
||
DEFAULT_DEPLOY_PORT = int(os.environ.get("DEPLOY_API_PORT", "4040"))
|
||
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 {
|
||
"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") or BT_API_KEY_DEFAULT).strip(),
|
||
# 与宝塔「Go 项目」名称一致;若启动失败可改环境变量 BT_GO_PROJECT_NAME(常见: wzApi / wzApi)
|
||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "wzApi"),
|
||
}
|
||
|
||
|
||
def run_build(root):
|
||
print("[1/4] 本地交叉编译 Go 二进制 ...")
|
||
env = os.environ.copy()
|
||
env["GOOS"] = "linux"
|
||
env["GOARCH"] = "amd64"
|
||
env["CGO_ENABLED"] = "0"
|
||
r = subprocess.run(
|
||
["go", "build", "-o", "wz-api", "./cmd/server"],
|
||
cwd=root,
|
||
env=env,
|
||
shell=False,
|
||
timeout=180,
|
||
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, "wz-api")
|
||
return out_path if os.path.isfile(out_path) else None
|
||
|
||
|
||
def set_env_port(env_path, port):
|
||
if not os.path.isfile(env_path):
|
||
return
|
||
lines = open(env_path, "r", encoding="utf-8", errors="replace").readlines()
|
||
found = False
|
||
out = []
|
||
for line in lines:
|
||
s = line.strip()
|
||
if "=" in s and s.split("=", 1)[0].strip() == "PORT":
|
||
out.append("PORT=%s\n" % port)
|
||
found = True
|
||
else:
|
||
out.append(line)
|
||
if not found:
|
||
out.append("PORT=%s\n" % port)
|
||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||
f.writelines(out)
|
||
|
||
|
||
def pack_deploy(root, binary_path, include_env=True):
|
||
print("[2/4] 打包部署文件 ...")
|
||
staging = tempfile.mkdtemp(prefix="wz_api_deploy_")
|
||
try:
|
||
shutil.copy2(binary_path, os.path.join(staging, "wz-api"))
|
||
env_src = os.path.join(root, ".env.production")
|
||
env_dst = os.path.join(staging, ".env")
|
||
if include_env and os.path.isfile(env_src):
|
||
shutil.copy2(env_src, env_dst)
|
||
set_env_port(env_dst, DEFAULT_DEPLOY_PORT)
|
||
tarball = os.path.join(tempfile.gettempdir(), "wzdj_api_deploy.tar.gz")
|
||
with tarfile.open(tarball, "w:gz") as tf:
|
||
for name in os.listdir(staging):
|
||
full = os.path.join(staging, name)
|
||
if name == "wz-api":
|
||
ti = tf.gettarinfo(full, arcname=name)
|
||
ti.mode = 0o755
|
||
with open(full, "rb") as bf:
|
||
tf.addfile(ti, bf)
|
||
else:
|
||
tf.add(full, arcname=name)
|
||
return tarball
|
||
finally:
|
||
shutil.rmtree(staging, ignore_errors=True)
|
||
|
||
|
||
def _bt_signed_post(base_url, key, path, extra_data, timeout=30):
|
||
req_time = int(time.time())
|
||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||
data = {"request_time": req_time, "request_token": req_token}
|
||
data.update(extra_data or {})
|
||
return requests.post(base_url + path, data=data, timeout=timeout, verify=False)
|
||
|
||
|
||
def _bt_parse_json(r):
|
||
if r is None or r.status_code != 200:
|
||
return None
|
||
try:
|
||
return r.json()
|
||
except Exception:
|
||
t = (r.text or "").lstrip()
|
||
if t.startswith("{"):
|
||
try:
|
||
return json.loads(r.text)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _response_debug_snippet(r, limit=480):
|
||
if r is None:
|
||
return "(无响应)"
|
||
try:
|
||
t = (r.text or "").replace("\r", " ").replace("\n", " ").strip()
|
||
except Exception:
|
||
t = ""
|
||
if len(t) > limit:
|
||
t = t[:limit] + "..."
|
||
return "HTTP %s | %s" % (r.status_code, t or "(empty body)")
|
||
|
||
|
||
def bt_panel_ping(cfg):
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
if not url or not key:
|
||
print("[失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
|
||
return False
|
||
req_time = int(time.time())
|
||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||
try:
|
||
r = requests.post(
|
||
url + "/system?action=GetDiskInfo",
|
||
data={"request_time": req_time, "request_token": req_token},
|
||
timeout=20,
|
||
verify=False,
|
||
)
|
||
if r.status_code != 200:
|
||
print("[宝塔API 探活] %s" % _response_debug_snippet(r))
|
||
return False
|
||
print("[宝塔API 探活] 成功(GetDiskInfo)")
|
||
return True
|
||
except Exception as e:
|
||
print("[宝塔API 探活] 异常: %s" % e)
|
||
return False
|
||
|
||
|
||
def bt_upload_file_resumable(cfg, local_path, remote_dir, remote_name):
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
total = os.path.getsize(local_path)
|
||
chunk_mb = max(1, int(os.environ.get("BT_UPLOAD_CHUNK_MB", "4") or "4"))
|
||
chunk_size = chunk_mb * 1024 * 1024
|
||
upload_url = url + "/files?action=upload"
|
||
headers = {"User-Agent": "Mozilla/5.0"}
|
||
f_start = 0
|
||
with open(local_path, "rb") as fp:
|
||
while f_start < total:
|
||
fp.seek(f_start)
|
||
buf = fp.read(min(chunk_size, total - f_start))
|
||
req_time = int(time.time())
|
||
sk_md5 = hashlib.md5(key.encode()).hexdigest()
|
||
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
|
||
data = {
|
||
"request_time": str(req_time),
|
||
"request_token": req_token,
|
||
"f_path": remote_dir,
|
||
"f_name": remote_name,
|
||
"f_size": str(total),
|
||
"f_start": str(f_start),
|
||
}
|
||
files = {"blob": ("blob", buf, "application/octet-stream")}
|
||
try:
|
||
r = requests.post(upload_url, data=data, files=files, headers=headers, timeout=600, verify=False)
|
||
except Exception as ex:
|
||
print(" [上传] 请求异常: %s" % ex)
|
||
return False
|
||
raw = (r.text or "").strip()
|
||
if raw.isdigit():
|
||
nxt = int(raw)
|
||
if nxt <= f_start:
|
||
print(" [上传] 断点续传偏移异常 f_start=%s next=%s" % (f_start, nxt))
|
||
return False
|
||
f_start = nxt
|
||
continue
|
||
j = _bt_parse_json(r)
|
||
if isinstance(j, dict) and j.get("status") is True:
|
||
return True
|
||
if isinstance(j, dict) and j.get("msg"):
|
||
print(" [上传] 面板返回: %s | %s" % (j.get("msg"), _response_debug_snippet(r)))
|
||
else:
|
||
print(" [上传] 非成功响应: %s" % _response_debug_snippet(r))
|
||
return False
|
||
return False
|
||
|
||
|
||
def bt_unzip_remote(cfg, sfile, dfile):
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
last_err = None
|
||
for type1 in ("tar.gz", "tgz", "tar"):
|
||
r = _bt_signed_post(
|
||
url,
|
||
key,
|
||
"/files?action=UnZip",
|
||
{"sfile": sfile, "dfile": dfile, "type1": type1, "coding": "UTF-8"},
|
||
timeout=180,
|
||
)
|
||
j = _bt_parse_json(r)
|
||
if isinstance(j, dict) and j.get("status") is True:
|
||
return True
|
||
if isinstance(j, dict):
|
||
last_err = j.get("msg") or j
|
||
else:
|
||
last_err = _response_debug_snippet(r)
|
||
if last_err is not None:
|
||
print(" [解压] 面板返回: %s (sfile=%s)" % (last_err, sfile))
|
||
return False
|
||
|
||
|
||
def bt_delete_file(cfg, remote_path):
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
j = _bt_parse_json(_bt_signed_post(url, key, "/files?action=DeleteFile", {"path": remote_path}))
|
||
return isinstance(j, dict) and j.get("status") is True
|
||
|
||
|
||
def restart_via_bt_api(cfg):
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
name = cfg["bt_go_project_name"]
|
||
site_id = (os.environ.get("BT_GO_SITE_ID") or "").strip()
|
||
if site_id:
|
||
stop = _bt_parse_json(_bt_signed_post(url, key, "/site?action=SiteStop", {"id": site_id, "name": name}))
|
||
time.sleep(2)
|
||
start = _bt_parse_json(_bt_signed_post(url, key, "/site?action=SiteStart", {"id": site_id, "name": name}))
|
||
ok = isinstance(stop, dict) and stop.get("status") is True and isinstance(start, dict) and start.get("status") is True
|
||
if not ok:
|
||
print(" [重启] SiteStop/Start 失败: stop=%s start=%s (项目名=%s id=%s)" % (stop, start, name, site_id))
|
||
return ok
|
||
for action in ("stop_go_project", "start_go_project"):
|
||
_bt_parse_json(_bt_signed_post(url, key, "/plugin?name=go_project", {"action": action, "project_name": name, "name": name}))
|
||
time.sleep(2)
|
||
r = _bt_signed_post(url, key, "/plugin?name=go_project", {"action": "start_go_project", "project_name": name, "name": name})
|
||
j = _bt_parse_json(r)
|
||
if isinstance(j, dict) and j.get("status") is True:
|
||
return True
|
||
msg = ""
|
||
if isinstance(j, dict):
|
||
msg = str(j.get("msg") or j)
|
||
print(" [重启] Go 项目启动失败(请核对宝塔中项目名是否与 BT_GO_PROJECT_NAME=%s 一致): %s" % (name, msg or _response_debug_snippet(r)))
|
||
return False
|
||
|
||
|
||
def bt_exec_shell(cfg, shell, cwd_path):
|
||
"""在服务器本机执行 Shell(需面板 API 白名单且允许 ExecShell)。"""
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
j = _bt_parse_json(_bt_signed_post(url, key, "/files?action=ExecShell", {"path": cwd_path, "shell": shell}, timeout=60))
|
||
if not isinstance(j, dict) or j.get("status") is not True:
|
||
print(" [重启/ExecShell] 未接受: %s" % (j,))
|
||
return False
|
||
return True
|
||
|
||
|
||
def bt_get_exec_shell_msg(cfg):
|
||
url = (cfg["bt_panel_url"] or "").rstrip("/")
|
||
key = cfg["bt_api_key"]
|
||
return _bt_parse_json(_bt_signed_post(url, key, "/files?action=GetExecShellMsg", {}, timeout=120))
|
||
|
||
|
||
def wait_exec_shell(cfg, timeout_sec=120, poll=2):
|
||
deadline = time.time() + timeout_sec
|
||
last_msg = None
|
||
while time.time() < deadline:
|
||
j = bt_get_exec_shell_msg(cfg)
|
||
last_msg = j
|
||
if isinstance(j, dict) and j.get("status") is True:
|
||
tx = str(j.get("msg") or "")
|
||
if tx:
|
||
tail = tx[-3500:] if len(tx) > 3500 else tx
|
||
print(" [重启/ExecShell] 输出摘录: %s" % tail.replace("\n", " ").strip())
|
||
return True
|
||
time.sleep(poll)
|
||
print(" [重启/ExecShell] 等待结束超时,最后返回: %s" % (last_msg,))
|
||
return False
|
||
|
||
|
||
def restart_via_bt_exec_shell(cfg):
|
||
"""Go 项目插件名不一致时的兜底:在项目目录内结束旧 wz-api 再起进程(与 docs/master SSH 逻辑一致)。"""
|
||
project_path = cfg["project_path"].rstrip("/")
|
||
if (os.environ.get("BT_NO_EXECSHELL_RESTART") or "").strip().lower() in ("1", "true", "yes"):
|
||
print(" [重启/ExecShell] 已通过 BT_NO_EXECSHELL_RESTART 跳过")
|
||
return False
|
||
shell = (
|
||
"T=$(readlink -f .); "
|
||
'for p in $(pgrep -f wz-api 2>/dev/null); do '
|
||
'[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && kill "$p" 2>/dev/null || true; '
|
||
"done; "
|
||
"sleep 2; chmod +x ./wz-api; "
|
||
"setsid nohup ./wz-api >>wz-api.log 2>&1 </dev/null & "
|
||
"sleep 3; "
|
||
'for p in $(pgrep -f wz-api 2>/dev/null); do '
|
||
'[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && echo WZ_API_RESTART_OK && exit 0; '
|
||
"done; "
|
||
"echo WZ_API_RESTART_FAIL; exit 1"
|
||
)
|
||
print(" [重启/ExecShell] 尝试在项目目录拉起 wz-api ...")
|
||
if not bt_exec_shell(cfg, shell, project_path):
|
||
return False
|
||
ok = wait_exec_shell(cfg, timeout_sec=150, poll=2)
|
||
if ok:
|
||
print(" [成功] 已通过 ExecShell 重启 wz-api(请用 /health 自检)")
|
||
return ok
|
||
|
||
|
||
def upload_and_extract(cfg, tarball_path, no_restart=False):
|
||
print("[3/4] 上传压缩包到宝塔 ...")
|
||
project_path = cfg["project_path"].rstrip("/")
|
||
tar_name = "wzdj_api_deploy.tar.gz"
|
||
remote_tar = project_path + "/" + tar_name
|
||
if not bt_upload_file_resumable(cfg, tarball_path, project_path, tar_name):
|
||
print("[失败] 上传失败")
|
||
return False
|
||
print("[4/4] 远程解压并重启 Go 项目 ...")
|
||
if not bt_unzip_remote(cfg, remote_tar, project_path + "/"):
|
||
print("[失败] 解压失败")
|
||
return False
|
||
bt_delete_file(cfg, remote_tar)
|
||
if not no_restart:
|
||
if restart_via_bt_api(cfg):
|
||
pass
|
||
else:
|
||
print(" [提示] Go 项目插件重启失败,改用 ExecShell 在项目目录拉起进程 ...")
|
||
if not restart_via_bt_exec_shell(cfg):
|
||
print("[失败] 重启失败(文件已上传并解压,请在面板手动启动或配置 BT_GO_PROJECT_NAME)")
|
||
return False
|
||
return True
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="玩值 wz-api 部署(宝塔 API)")
|
||
parser.add_argument("--no-build", action="store_true")
|
||
parser.add_argument("--no-env", action="store_true")
|
||
parser.add_argument("--no-restart", action="store_true")
|
||
parser.add_argument("--skip-bt-ping", action="store_true")
|
||
args = parser.parse_args()
|
||
|
||
root = os.path.dirname(os.path.abspath(__file__))
|
||
cfg = get_cfg()
|
||
print("=" * 60)
|
||
print("wz-api 部署")
|
||
print("目标目录:", cfg["project_path"])
|
||
print("=" * 60)
|
||
|
||
if not requests:
|
||
print("[失败] 需要 requests:pip install requests")
|
||
return 1
|
||
if not args.skip_bt_ping and not bt_panel_ping(cfg):
|
||
print("[失败] 宝塔 API 探活未通过")
|
||
return 1
|
||
|
||
binary_path = os.path.join(root, "wz-api")
|
||
if not args.no_build:
|
||
binary_path = run_build(root)
|
||
if not binary_path:
|
||
return 1
|
||
elif not os.path.isfile(binary_path):
|
||
print("[失败] 未找到现有二进制 wz-api")
|
||
return 1
|
||
|
||
tarball = pack_deploy(root, binary_path, include_env=not args.no_env)
|
||
ok = upload_and_extract(cfg, tarball, no_restart=args.no_restart) if tarball else False
|
||
try:
|
||
if tarball:
|
||
os.remove(tarball)
|
||
except Exception:
|
||
pass
|
||
if not ok:
|
||
print("[失败] 部署未完成")
|
||
return 1
|
||
print("部署完成:", cfg["project_path"])
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|