4a4de8f5fb
Ship deploy/manage.sh toolkit for /opt/coinox, rewrite Ubuntu-only deploy docs, drop Windows deploy guidance. Co-authored-by: Cursor <cursoragent@cursor.com>
514 lines
14 KiB
Bash
514 lines
14 KiB
Bash
#!/usr/bin/env bash
|
||
# deploy/lib/common.sh — CoinOX 部署公共函数
|
||
set -e
|
||
set -u
|
||
if [ -n "${BASH_VERSION:-}" ]; then
|
||
set -o pipefail
|
||
fi
|
||
|
||
INSTALL_ROOT="${INSTALL_ROOT:-/opt/coinox}"
|
||
GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/CoinOX.git}"
|
||
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||
BACKUP_ROOT="${BACKUP_ROOT:-/root/backups}"
|
||
TZ_NAME="${CM_TZ:-Asia/Shanghai}"
|
||
NODE_MAJOR="${NODE_MAJOR:-20}"
|
||
APP_PORT="${APP_PORT:-5010}"
|
||
PM2_APP_NAME="${PM2_APP_NAME:-coinox}"
|
||
|
||
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
DEPLOY_DIR="$(cd "${LIB_DIR}/.." && pwd)"
|
||
REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||
|
||
PM2_APPS=(
|
||
coinox
|
||
)
|
||
|
||
# 历史进程名(仅 stop/delete 时兼容)
|
||
PM2_APPS_LEGACY=(
|
||
crypto-monitor-okx
|
||
crypto-monitor
|
||
)
|
||
|
||
CONFIG_PATHS=(
|
||
.env
|
||
)
|
||
|
||
log() { printf '[%s] %s\n' "$(TZ="${TZ_NAME}" date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||
step() { echo ""; log "==> $*"; }
|
||
|
||
die() {
|
||
echo "错误: $*" >&2
|
||
exit 1
|
||
}
|
||
|
||
require_root() {
|
||
if [[ "$(id -u)" -ne 0 ]]; then
|
||
die "请使用 root 执行(推荐: sudo -i 后运行)"
|
||
fi
|
||
}
|
||
|
||
require_ubuntu() {
|
||
if [[ ! -f /etc/os-release ]]; then
|
||
log "警告: 未检测到 /etc/os-release,跳过 Ubuntu 版本检查"
|
||
return 0
|
||
fi
|
||
# shellcheck source=/dev/null
|
||
source /etc/os-release
|
||
if [[ "${ID:-}" != "ubuntu" ]]; then
|
||
log "警告: 当前系统为 ${ID:-unknown},官方仅测试 Ubuntu 22.04/24.04"
|
||
return 0
|
||
fi
|
||
local ver="${VERSION_ID:-}"
|
||
if [[ "${ver}" != "22.04" && "${ver}" != "24.04" ]]; then
|
||
log "警告: Ubuntu ${ver} 未在文档中明确测试,继续执行"
|
||
fi
|
||
}
|
||
|
||
detect_server_ip() {
|
||
local ip=""
|
||
if command -v hostname >/dev/null 2>&1; then
|
||
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||
fi
|
||
if [[ -z "${ip}" ]]; then
|
||
ip="127.0.0.1"
|
||
fi
|
||
echo "${ip}"
|
||
}
|
||
|
||
confirm_yes() {
|
||
local msg="$1"
|
||
local ans=""
|
||
cm_read ans "${msg} [y/N] "
|
||
[[ "${ans}" == [yY] || "${ans}" == [yY][eE][sS] ]]
|
||
}
|
||
|
||
confirm_uninstall() {
|
||
local ans=""
|
||
echo "此操作将停止 CoinOX PM2 进程并移走安装目录."
|
||
cm_read ans "输入 UNINSTALL 确认卸载: "
|
||
[[ "${ans}" == "UNINSTALL" ]]
|
||
}
|
||
|
||
# curl | bash 时 stdin 是管道,须从 /dev/tty 读取用户输入
|
||
cm_read() {
|
||
local __var="$1"
|
||
local __prompt="$2"
|
||
local __val=""
|
||
if [[ -r /dev/tty ]]; then
|
||
IFS= read -r -p "${__prompt}" __val </dev/tty || true
|
||
else
|
||
IFS= read -r -p "${__prompt}" __val || true
|
||
fi
|
||
printf -v "${__var}" '%s' "${__val}"
|
||
}
|
||
|
||
repo_ready() {
|
||
[[ -f "${1}/deploy/setup_env.sh" && -f "${1}/deploy/manage.sh" && -f "${1}/app.py" ]]
|
||
}
|
||
|
||
resolve_repo_root() {
|
||
if repo_ready "${INSTALL_ROOT}"; then
|
||
echo "${INSTALL_ROOT}"
|
||
return 0
|
||
fi
|
||
if repo_ready "${REPO_ROOT}"; then
|
||
echo "${REPO_ROOT}"
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
backup_configs_to() {
|
||
local src_root="$1"
|
||
local dest="$2"
|
||
mkdir -p "${dest}"
|
||
local rel copied=0
|
||
for rel in "${CONFIG_PATHS[@]}"; do
|
||
local src="${src_root}/${rel}"
|
||
if [[ -f "${src}" ]]; then
|
||
mkdir -p "${dest}/$(dirname "${rel}")"
|
||
cp -a "${src}" "${dest}/${rel}"
|
||
log "backup ${rel}"
|
||
copied=$((copied + 1))
|
||
fi
|
||
done
|
||
if [[ "${copied}" -eq 0 ]]; then
|
||
log "提示: 未找到可备份的配置文件"
|
||
fi
|
||
}
|
||
|
||
install_system_packages() {
|
||
step "安装系统依赖"
|
||
if ! command -v apt-get >/dev/null 2>&1; then
|
||
die "未检测到 apt-get,请手动安装 python3-venv git curl"
|
||
fi
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
apt-get update -qq
|
||
apt-get install -y python3 python3-pip python3-venv curl git ca-certificates
|
||
local pyver=""
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
pyver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||
apt-get install -y "python${pyver}-venv" 2>/dev/null || apt-get install -y python3-venv
|
||
fi
|
||
}
|
||
|
||
install_node_pm2() {
|
||
step "检查 Node.js 与 PM2"
|
||
if command -v pm2 >/dev/null 2>&1; then
|
||
log "PM2 已安装: $(pm2 -v)"
|
||
return 0
|
||
fi
|
||
if ! command -v node >/dev/null 2>&1; then
|
||
log "安装 Node.js ${NODE_MAJOR}.x ..."
|
||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
|
||
apt-get install -y nodejs
|
||
fi
|
||
log "安装 PM2 ..."
|
||
npm install -g pm2
|
||
log "PM2: $(pm2 -v)"
|
||
}
|
||
|
||
pip_progress_bar_arg() {
|
||
echo "on"
|
||
}
|
||
|
||
pip_upgrade_tools() {
|
||
local pip_bin="$1"
|
||
local bar
|
||
bar="$(pip_progress_bar_arg)"
|
||
echo " 升级 pip ..."
|
||
"${pip_bin}" install -U pip setuptools wheel \
|
||
--disable-pip-version-check \
|
||
--progress-bar "${bar}"
|
||
}
|
||
|
||
pip_install_requirements() {
|
||
local pip_bin="$1"
|
||
local req_file="$2"
|
||
local label="${3:-依赖}"
|
||
local bar
|
||
bar="$(pip_progress_bar_arg)"
|
||
echo " 安装${label} (下方为 pip 进度) ..."
|
||
[[ -f "${req_file}" ]] || die "缺少依赖文件: ${req_file}"
|
||
"${pip_bin}" install -r "${req_file}" \
|
||
--disable-pip-version-check \
|
||
--retries 5 \
|
||
--progress-bar "${bar}"
|
||
echo " ${label}安装完成"
|
||
}
|
||
|
||
install_backup_cron() {
|
||
step "安装每日备份 cron"
|
||
local inst="${REPO_ROOT}/scripts/install_backup_cron.sh"
|
||
if [[ -f "${inst}" ]]; then
|
||
chmod +x "${inst}"
|
||
bash "${inst}" || log "警告: cron 安装失败"
|
||
else
|
||
log "未找到 scripts/install_backup_cron.sh,跳过"
|
||
fi
|
||
}
|
||
|
||
remove_backup_cron() {
|
||
step "移除备份 cron"
|
||
local tmp removed=0
|
||
tmp="$(mktemp)"
|
||
if ! crontab -l 2>/dev/null >"${tmp}"; then
|
||
rm -f "${tmp}"
|
||
return 0
|
||
fi
|
||
local filtered
|
||
filtered="$(grep -vF "backup_data.sh" "${tmp}" || true)"
|
||
if [[ "${filtered}" != "$(cat "${tmp}")" ]]; then
|
||
printf '%s\n' "${filtered}" | awk '
|
||
BEGIN { tz = 0 }
|
||
/^CRON_TZ=Asia\/Shanghai$/ {
|
||
if (tz++) next
|
||
}
|
||
{ print }
|
||
' | crontab -
|
||
removed=1
|
||
fi
|
||
rm -f "${tmp}"
|
||
if [[ "${removed}" -eq 1 ]]; then
|
||
log "已移除 backup_data.sh 相关 cron"
|
||
fi
|
||
}
|
||
|
||
pm2_app_exists() {
|
||
local name="$1"
|
||
# pm2 pid 在部分版本对不存在的进程仍返回 0;改用 jlist 精确匹配 name
|
||
if ! command -v pm2 >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
if ! command -v python3 >/dev/null 2>&1; then
|
||
pm2 describe "${name}" 2>/dev/null | grep -q "status"
|
||
return $?
|
||
fi
|
||
pm2 jlist 2>/dev/null | python3 -c '
|
||
import json,sys
|
||
name=sys.argv[1]
|
||
try:
|
||
apps=json.load(sys.stdin)
|
||
except Exception:
|
||
sys.exit(1)
|
||
sys.exit(0 if any(isinstance(a,dict) and a.get("name")==name for a in (apps or [])) else 1)
|
||
' "${name}"
|
||
}
|
||
|
||
pm2_stop_project_apps() {
|
||
if ! command -v pm2 >/dev/null 2>&1; then
|
||
log "未安装 pm2,跳过"
|
||
return 0
|
||
fi
|
||
local name stopped=0
|
||
for name in "${PM2_APPS[@]}" "${PM2_APPS_LEGACY[@]}"; do
|
||
if pm2_app_exists "${name}"; then
|
||
log "pm2 stop ${name}"
|
||
pm2 stop "${name}" 2>/dev/null || true
|
||
stopped=$((stopped + 1))
|
||
fi
|
||
done
|
||
if [[ "${stopped}" -eq 0 ]]; then
|
||
log "未发现本项目 PM2 进程(其它 PM2 不受影响)"
|
||
fi
|
||
}
|
||
|
||
pm2_delete_project_apps() {
|
||
if ! command -v pm2 >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
local name deleted=0
|
||
for name in "${PM2_APPS[@]}" "${PM2_APPS_LEGACY[@]}"; do
|
||
if pm2_app_exists "${name}"; then
|
||
log "pm2 delete ${name}"
|
||
pm2 delete "${name}" 2>/dev/null || true
|
||
deleted=$((deleted + 1))
|
||
fi
|
||
done
|
||
if [[ "${deleted}" -gt 0 ]]; then
|
||
pm2 save 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
pm2_start_app() {
|
||
step "启动 PM2: ${PM2_APP_NAME}"
|
||
cd "${REPO_ROOT}"
|
||
[[ -f "${REPO_ROOT}/ecosystem.config.cjs" ]] || die "缺少 ecosystem.config.cjs"
|
||
[[ -x "${REPO_ROOT}/.venv/bin/python" ]] || die "缺少 .venv,请先运行 setup_env.sh"
|
||
if pm2_app_exists "${PM2_APP_NAME}"; then
|
||
log "已存在,执行 restart --update-env"
|
||
if ! pm2 restart "${PM2_APP_NAME}" --update-env; then
|
||
log "restart 失败,改为 start ecosystem.config.cjs"
|
||
pm2 start ecosystem.config.cjs
|
||
fi
|
||
else
|
||
log "首次启动: pm2 start ecosystem.config.cjs"
|
||
pm2 start ecosystem.config.cjs
|
||
fi
|
||
}
|
||
|
||
pm2_save_startup() {
|
||
step "PM2 save & startup"
|
||
pm2 save 2>/dev/null || true
|
||
if pm2 startup systemd -u root --hp /root 2>/dev/null | grep -q "sudo"; then
|
||
pm2 startup systemd -u root --hp /root 2>/dev/null | grep "^sudo" | bash || true
|
||
else
|
||
pm2 startup 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
is_deployed() {
|
||
local root="$1"
|
||
[[ -x "${root}/.venv/bin/python" ]]
|
||
}
|
||
|
||
read_app_port() {
|
||
local env_file="${REPO_ROOT}/.env"
|
||
local port="${APP_PORT}"
|
||
if [[ -f "${env_file}" ]]; then
|
||
local line
|
||
line="$(grep -E '^[[:space:]]*APP_PORT=' "${env_file}" | tail -n1 || true)"
|
||
if [[ -n "${line}" ]]; then
|
||
port="${line#*=}"
|
||
port="$(echo "${port}" | tr -d '"' | tr -d "'" | xargs)"
|
||
fi
|
||
fi
|
||
echo "${port:-5010}"
|
||
}
|
||
|
||
check_http() {
|
||
local url="$1"
|
||
local code
|
||
code="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 8 "${url}" 2>/dev/null || echo "000")"
|
||
[[ "${code}" == "200" || "${code}" == "302" || "${code}" == "301" || "${code}" == "401" || "${code}" == "403" ]]
|
||
}
|
||
|
||
verify_deployment() {
|
||
local ip="${1:-$(detect_server_ip)}"
|
||
local port
|
||
local ok=1
|
||
local attempt max_attempts=20
|
||
port="$(read_app_port)"
|
||
|
||
step "部署验收"
|
||
if command -v pm2 >/dev/null 2>&1 && pm2_app_exists "${PM2_APP_NAME}"; then
|
||
echo " [✓] PM2 ${PM2_APP_NAME} 已注册"
|
||
else
|
||
echo " [✗] PM2 缺少进程 ${PM2_APP_NAME}"
|
||
ok=0
|
||
pm2 list 2>/dev/null || true
|
||
fi
|
||
|
||
# Flask 冷启动常需数秒;立刻 curl 易误报
|
||
echo " ... 等待页面就绪 (最多约 ${max_attempts}s,端口 ${port})"
|
||
local http_ok=0
|
||
for attempt in $(seq 1 "${max_attempts}"); do
|
||
if check_http "http://127.0.0.1:${port}/"; then
|
||
http_ok=1
|
||
break
|
||
fi
|
||
# 进程已挂掉则提前失败
|
||
if command -v pm2 >/dev/null 2>&1 && ! pm2_app_exists "${PM2_APP_NAME}"; then
|
||
echo " [✗] 等待期间 PM2 进程消失"
|
||
ok=0
|
||
break
|
||
fi
|
||
local status=""
|
||
status="$(pm2 jlist 2>/dev/null | python3 -c '
|
||
import json,sys
|
||
name="'"${PM2_APP_NAME}"'"
|
||
try:
|
||
apps=json.load(sys.stdin)
|
||
except Exception:
|
||
print(""); sys.exit(0)
|
||
for a in apps or []:
|
||
if isinstance(a,dict) and a.get("name")==name:
|
||
print((a.get("pm2_env") or {}).get("status") or "")
|
||
break
|
||
' 2>/dev/null || true)"
|
||
if [[ "${status}" == "errored" || "${status}" == "stopped" ]]; then
|
||
echo " [✗] PM2 状态为 ${status}"
|
||
ok=0
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
if [[ "${http_ok}" -eq 1 ]]; then
|
||
echo " [✓] 页面可访问 (http://127.0.0.1:${port}/)"
|
||
else
|
||
echo " [✗] 页面不可访问 (http://127.0.0.1:${port}/)"
|
||
ok=0
|
||
echo " 排查: pm2 logs ${PM2_APP_NAME} --lines 50"
|
||
echo " 确认端口: grep APP_PORT ${REPO_ROOT}/.env"
|
||
fi
|
||
|
||
if [[ "${ok}" -eq 1 ]]; then
|
||
echo ""
|
||
log "验收通过: 进程在线 + 页面可打开"
|
||
return 0
|
||
fi
|
||
echo ""
|
||
log "验收未完全通过,可执行: pm2 logs ${PM2_APP_NAME} --lines 30"
|
||
return 1
|
||
}
|
||
|
||
print_post_install_guide() {
|
||
local ip="${1:-$(detect_server_ip)}"
|
||
local port
|
||
port="$(read_app_port)"
|
||
cat <<EOF
|
||
|
||
═══════════════════════════════════════════
|
||
✅ CoinOX 部署完成(默认模拟盘 sim)
|
||
|
||
访问: http://${ip}:${port}
|
||
入口: /hedge-plan(对冲) /options(期权)
|
||
|
||
登录账号: admin
|
||
登录密码: 见 .env 中 APP_PASSWORD(首次多为 admin123)
|
||
(请尽快在系统设置 / env 配置中修改)
|
||
|
||
模式说明:
|
||
- SIM_DEFAULT_MODE=sim(本地模拟资金,不打真实私有下单)
|
||
- 实盘: 在页面/env 切 live,并配置 OKX_API_* 与 LIVE_TRADING_ENABLED
|
||
|
||
下一步:
|
||
1. 浏览器打开上方地址
|
||
2. 模拟盘直接测期权 / 对冲计划
|
||
3. 需要实盘时再填 API / 代理并切 live
|
||
|
||
管理:
|
||
bash ${INSTALL_ROOT}/deploy/manage.sh
|
||
仓库:
|
||
${GIT_URL}
|
||
═══════════════════════════════════════════
|
||
EOF
|
||
}
|
||
|
||
doctor_report() {
|
||
step "环境识别"
|
||
if [[ -f /etc/os-release ]]; then
|
||
# shellcheck source=/dev/null
|
||
source /etc/os-release
|
||
echo " OS: ${PRETTY_NAME:-${ID:-unknown} ${VERSION_ID:-}}"
|
||
else
|
||
echo " OS: unknown"
|
||
fi
|
||
echo " 用户: $(id -un) (uid=$(id -u))"
|
||
echo " 主机: $(hostname 2>/dev/null || echo '?')"
|
||
echo " IP: $(detect_server_ip)"
|
||
echo " 安装根: ${INSTALL_ROOT}"
|
||
echo " 仓库: ${GIT_URL} (${GIT_BRANCH})"
|
||
|
||
step "依赖检测"
|
||
local item cmd
|
||
for item in "python3:python3" "git:git" "curl:curl" "node:node" "npm:npm" "pm2:pm2"; do
|
||
cmd="${item#*:}"
|
||
if command -v "${cmd}" >/dev/null 2>&1; then
|
||
case "${cmd}" in
|
||
python3) echo " [✓] python3: $(python3 --version 2>&1)" ;;
|
||
node) echo " [✓] node: $(node -v 2>&1)" ;;
|
||
npm) echo " [✓] npm: $(npm -v 2>&1)" ;;
|
||
pm2) echo " [✓] pm2: $(pm2 -v 2>&1)" ;;
|
||
*) echo " [✓] ${cmd}: $(command -v "${cmd}")" ;;
|
||
esac
|
||
else
|
||
echo " [✗] ${cmd}: 未安装"
|
||
fi
|
||
done
|
||
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
local ver major minor
|
||
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||
major="${ver%%.*}"
|
||
minor="${ver#*.}"
|
||
if [[ "${major}" -gt 3 ]] || [[ "${major}" -eq 3 && "${minor}" -ge 10 ]]; then
|
||
echo " [✓] Python 版本满足 3.10+ (${ver})"
|
||
else
|
||
echo " [✗] Python 需要 3.10+,当前 ${ver}"
|
||
fi
|
||
fi
|
||
|
||
step "项目状态"
|
||
local root=""
|
||
if root="$(resolve_repo_root 2>/dev/null)"; then
|
||
echo " [✓] 仓库就绪: ${root}"
|
||
[[ -f "${root}/requirements.txt" ]] && echo " [✓] requirements.txt" || echo " [✗] requirements.txt"
|
||
[[ -f "${root}/ecosystem.config.cjs" ]] && echo " [✓] ecosystem.config.cjs" || echo " [✗] ecosystem.config.cjs"
|
||
[[ -f "${root}/.env" ]] && echo " [✓] .env 已存在" || echo " [!] .env 尚未生成"
|
||
if [[ -x "${root}/.venv/bin/python" ]]; then
|
||
echo " [✓] .venv: $("${root}/.venv/bin/python" --version 2>&1)"
|
||
else
|
||
echo " [!] .venv 未创建"
|
||
fi
|
||
else
|
||
echo " [!] 尚未安装到 ${INSTALL_ROOT}"
|
||
fi
|
||
|
||
if command -v pm2 >/dev/null 2>&1; then
|
||
step "PM2"
|
||
pm2 list 2>/dev/null | head -n 40 || true
|
||
fi
|
||
}
|