Files
wzdj/new/wz-api/master.py

437 lines
16 KiB
Python
Raw Permalink 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 -*-
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("[失败] 需要 requestspip 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())