Support up to 3 strategy slots on one host with auto ports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-01 23:33:41 +08:00
parent 9c515644a3
commit aab0b9bdf6
8 changed files with 503 additions and 200 deletions
+1
View File
@@ -1,3 +1,4 @@
// 默认槽位 1 模板;安装/更新时由 deploy/lib/common.sh#write_ecosystem_config 按目录重写。
module.exports = { module.exports = {
apps: [ apps: [
{ {
+164
View File
@@ -15,6 +15,7 @@ TZ_NAME="${EHS_TZ:-Asia/Shanghai}"
NODE_MAJOR="${NODE_MAJOR:-20}" NODE_MAJOR="${NODE_MAJOR:-20}"
PM2_APP_NAME="${PM2_APP_NAME:-eth-hedge-api}" PM2_APP_NAME="${PM2_APP_NAME:-eth-hedge-api}"
API_PORT="${API_PORT:-5155}" API_PORT="${API_PORT:-5155}"
INSTANCE_SLOT="${INSTANCE_SLOT:-1}"
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="$(cd "${LIB_DIR}/.." && pwd)" DEPLOY_DIR="$(cd "${LIB_DIR}/.." && pwd)"
@@ -28,6 +29,167 @@ die() {
exit 1 exit 1
} }
# ---------- 同机多套槽位(1/2/3 ----------
infer_slot_from_root() {
local root="${1%/}"
case "${root}" in
*/eth_hedge_sim_2) echo 2 ;;
*/eth_hedge_sim_3) echo 3 ;;
*) echo 1 ;;
esac
}
# 设置 INSTANCE_SLOT / INSTALL_ROOT / API_PORT / PM2_APP_NAME
resolve_slot() {
local slot="${1:-${INSTANCE_SLOT:-1}}"
case "${slot}" in
1|2|3) ;;
*) die "无效槽位: ${slot}(仅支持 1/2/3" ;;
esac
INSTANCE_SLOT="${slot}"
case "${slot}" in
1)
INSTALL_ROOT="/opt/eth_hedge_sim"
API_PORT="5155"
PM2_APP_NAME="eth-hedge-api"
;;
2)
INSTALL_ROOT="/opt/eth_hedge_sim_2"
API_PORT="5156"
PM2_APP_NAME="eth-hedge-api-2"
;;
3)
INSTALL_ROOT="/opt/eth_hedge_sim_3"
API_PORT="5157"
PM2_APP_NAME="eth-hedge-api-3"
;;
esac
export INSTANCE_SLOT INSTALL_ROOT API_PORT PM2_APP_NAME
}
# 按实际仓库目录对齐槽位(pull/update 时以目录为准)
apply_slot_for_root() {
local root="${1%/}"
[[ -n "${root}" ]] || die "apply_slot_for_root: 空目录"
resolve_slot "$(infer_slot_from_root "${root}")"
INSTALL_ROOT="${root}"
REPO_ROOT="${root}"
# 已有 .env 时保留用户改过的端口
if [[ -f "${root}/.env" ]]; then
local p=""
p="$(grep -E '^API_PORT=' "${root}/.env" 2>/dev/null | head -n1 | cut -d= -f2- | tr -d '\r' || true)"
if [[ -n "${p}" && "${p}" =~ ^[0-9]+$ ]]; then
API_PORT="${p}"
fi
fi
export INSTANCE_SLOT INSTALL_ROOT API_PORT PM2_APP_NAME REPO_ROOT
}
is_allowed_install_root() {
local root="${1%/}"
case "${root}" in
/opt/eth_hedge_sim|/opt/eth_hedge_sim_2|/opt/eth_hedge_sim_3) return 0 ;;
*) return 1 ;;
esac
}
port_is_listening() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | grep -qE ":${port}[[:space:]]" && return 0
fi
if command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 && return 0
fi
return 1
}
# 全新安装前:端口已被占用且不是本槽位 PM2 → 失败
assert_port_free_for_install() {
local port="${1:-${API_PORT}}"
local name="${2:-${PM2_APP_NAME}}"
if command -v pm2 >/dev/null 2>&1; then
if pm2 describe "${name}" >/dev/null 2>&1; then
return 0
fi
fi
if port_is_listening "${port}"; then
die "端口 ${port} 已被占用(槽位 ${INSTANCE_SLOT} / ${name})。请释放端口或换槽位"
fi
}
write_ecosystem_config() {
local root="${1:-${INSTALL_ROOT}}"
local name="${2:-${PM2_APP_NAME}}"
local port="${3:-${API_PORT}}"
local out="${root}/deploy/ecosystem.config.cjs"
mkdir -p "${root}/deploy"
cat >"${out}" <<EOF
module.exports = {
apps: [
{
name: '${name}',
cwd: '${root}/backend',
script: '${root}/.venv/bin/uvicorn',
args: 'app.main:app --host 0.0.0.0 --port ${port}',
interpreter: 'none',
env: {
// MODE 以仓库根 .env 为准(设置页可切 SIM/LIVE),勿在此硬编码覆盖
ENV_NAME: 'test',
TZ: 'Asia/Shanghai',
},
// 密钥与本地配置一律读 ${root}/.env(勿写进本文件)
},
],
};
EOF
log "已写入 PM2: name=${name} port=${port}${out}"
}
random_auth_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 24
return 0
fi
head -c 32 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48
echo ""
}
# SKIP_SLOT_PROMPT=1 时用环境变量 INSTANCE_SLOT(默认 1),供「全部更新」等非交互调用
prompt_instance_slot() {
local line=""
local slot=""
if [[ "${SKIP_SLOT_PROMPT:-0}" == "1" ]]; then
resolve_slot "${INSTANCE_SLOT:-1}"
log "槽位 ${INSTANCE_SLOT}: ${INSTALL_ROOT} port=${API_PORT} pm2=${PM2_APP_NAME}"
return 0
fi
echo ""
echo "策略机槽位(同机最多 3 套,互不影响):"
echo " 1 → /opt/eth_hedge_sim :5155 eth-hedge-api"
echo " 2 → /opt/eth_hedge_sim_2 :5156 eth-hedge-api-2"
echo " 3 → /opt/eth_hedge_sim_3 :5157 eth-hedge-api-3"
cm_read line "请选择槽位 [1/2/3,默认1]: "
slot="${line:-1}"
resolve_slot "${slot}"
log "槽位 ${INSTANCE_SLOT}: ${INSTALL_ROOT} port=${API_PORT} pm2=${PM2_APP_NAME}"
}
list_installed_slots() {
local s root
for s in 1 2 3; do
case "${s}" in
1) root="/opt/eth_hedge_sim" ;;
2) root="/opt/eth_hedge_sim_2" ;;
3) root="/opt/eth_hedge_sim_3" ;;
esac
if repo_ready "${root}"; then
echo "${s}"
fi
done
}
require_root() { require_root() {
if [[ "$(id -u)" -ne 0 ]]; then if [[ "$(id -u)" -ne 0 ]]; then
die "请使用 root 执行(推荐: sudo -i 后运行)" die "请使用 root 执行(推荐: sudo -i 后运行)"
@@ -360,11 +522,13 @@ print_post_install_guide() {
echo "" echo ""
echo "══════════════════════════════════════" echo "══════════════════════════════════════"
echo " 比特骆驼自动化对冲系统 部署完成" echo " 比特骆驼自动化对冲系统 部署完成"
echo " 槽位: ${INSTANCE_SLOT:-1}"
echo " 目录: ${INSTALL_ROOT}" echo " 目录: ${INSTALL_ROOT}"
echo " 本机: http://${ip}:${API_PORT}/health" echo " 本机: http://${ip}:${API_PORT}/health"
echo " 进程: pm2 status → ${PM2_APP_NAME}" echo " 进程: pm2 status → ${PM2_APP_NAME}"
echo " 管理: bash ${INSTALL_ROOT}/deploy/manage.sh" echo " 管理: bash ${INSTALL_ROOT}/deploy/manage.sh"
echo " 配置: ${INSTALL_ROOT}/.env" echo " 配置: ${INSTALL_ROOT}/.env"
echo " 同机多套: 菜单选槽位 2/3;实盘请只用槽位 1"
echo "══════════════════════════════════════" echo "══════════════════════════════════════"
echo "" echo ""
} }
+18 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy/lib/install.sh — 一键部署策略机 # deploy/lib/install.sh — 一键部署策略机(支持槽位 1/2/3
set -e set -e
set -u set -u
if [ -n "${BASH_VERSION:-}" ]; then if [ -n "${BASH_VERSION:-}" ]; then
@@ -13,10 +13,12 @@ source "${LIB_DIR}/common.sh"
run_pipeline() { run_pipeline() {
local root="$1" local root="$1"
REPO_ROOT="${root}" REPO_ROOT="${root}"
step "构建并启动策略机 (pull_and_restart.sh)" INSTALL_ROOT="${root}"
apply_slot_for_root "${root}"
step "构建并启动策略机 (pull_and_restart.sh) slot=${INSTANCE_SLOT}"
bash "${REPO_ROOT}/deploy/pull_and_restart.sh" bash "${REPO_ROOT}/deploy/pull_and_restart.sh"
pm2_save_startup pm2_save_startup
verify_health || true verify_health "${API_PORT}" || true
print_post_install_guide print_post_install_guide
} }
@@ -24,7 +26,8 @@ install_fresh() {
require_root require_root
step "一键部署策略机 — 环境检测与依赖" step "一键部署策略机 — 环境检测与依赖"
ensure_system_deps ensure_system_deps
step "克隆仓库 → ${INSTALL_ROOT}" assert_port_free_for_install "${API_PORT}" "${PM2_APP_NAME}"
step "克隆仓库 → ${INSTALL_ROOT}(槽位 ${INSTANCE_SLOT} / :${API_PORT}"
if [[ -d "${INSTALL_ROOT}" ]]; then if [[ -d "${INSTALL_ROOT}" ]]; then
die "目录已存在: ${INSTALL_ROOT},请先卸载或选修复" die "目录已存在: ${INSTALL_ROOT},请先卸载或选修复"
fi fi
@@ -40,13 +43,13 @@ install_repair() {
if ! REPO_ROOT="$(resolve_repo_root)"; then if ! REPO_ROOT="$(resolve_repo_root)"; then
die "未找到安装目录 ${INSTALL_ROOT}" die "未找到安装目录 ${INSTALL_ROOT}"
fi fi
step "修复环境(保留 .env 与数据库)" step "修复环境(保留 .env 与数据库) slot=${INSTANCE_SLOT}"
run_pipeline "${REPO_ROOT}" run_pipeline "${REPO_ROOT}"
} }
handle_existing() { handle_existing() {
echo "" echo ""
echo "检测到已部署: ${INSTALL_ROOT}" echo "检测到已部署: ${INSTALL_ROOT}(槽位 ${INSTANCE_SLOT}"
echo " a) 取消" echo " a) 取消"
echo " b) 修复/重装环境(保留 .env 与 SQLite)" echo " b) 修复/重装环境(保留 .env 与 SQLite)"
local choice="" local choice=""
@@ -59,6 +62,15 @@ handle_existing() {
main_install() { main_install() {
require_root require_root
# manage.sh 已 prompt;直接调用时按 INSTANCE_SLOT / INSTALL_ROOT 对齐
if [[ "${INSTALL_ROOT}" == "/opt/eth_hedge_sim_2" ]]; then
resolve_slot 2
elif [[ "${INSTALL_ROOT}" == "/opt/eth_hedge_sim_3" ]]; then
resolve_slot 3
else
resolve_slot "${INSTANCE_SLOT:-1}"
fi
if repo_ready "${INSTALL_ROOT}"; then if repo_ready "${INSTALL_ROOT}"; then
handle_existing handle_existing
else else
+27 -24
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy/lib/uninstall.sh — 一键卸载(仅本项目 PM2,删除 /opt 安装目录) # deploy/lib/uninstall.sh — 一键卸载指定槽位(仅该槽位 PM2 + 目录)
set -e set -e
set -u set -u
if [ -n "${BASH_VERSION:-}" ]; then if [ -n "${BASH_VERSION:-}" ]; then
@@ -13,6 +13,8 @@ source "${LIB_DIR}/common.sh"
main_uninstall() { main_uninstall() {
require_root require_root
resolve_slot "${INSTANCE_SLOT:-1}"
local root="" local root=""
if ! root="$(resolve_repo_root)"; then if ! root="$(resolve_repo_root)"; then
if [[ -d "${INSTALL_ROOT}" ]]; then if [[ -d "${INSTALL_ROOT}" ]]; then
@@ -22,13 +24,15 @@ main_uninstall() {
fi fi
fi fi
REPO_ROOT="${root}" REPO_ROOT="${root}"
INSTALL_ROOT="${root}"
apply_slot_for_root "${root}"
echo "" echo ""
echo "将卸载 比特骆驼自动化对冲系统 (eth_hedge_sim):" echo "将卸载 比特骆驼自动化对冲系统 槽位 ${INSTANCE_SLOT}:"
echo " - 停止并删除 PM2 进程: ${PM2_APP_NAME}" echo " - 停止并删除 PM2 进程: ${PM2_APP_NAME}"
echo " - 删除安装目录: ${INSTALL_ROOT}" echo " - 删除安装目录: ${INSTALL_ROOT}"
echo " - 备份 .env / 数据库到 ${BACKUP_ROOT}" echo " - 备份 .env / 数据库到 ${BACKUP_ROOT}"
echo " - 不影响其它 PM2 应用 / 不卸载系统 Python、Node、PM2" echo " - 不影响其它槽位 / 其它 PM2 / 系统 Python、Node、PM2"
echo "" echo ""
if ! confirm_yes "确认卸载并删除 ${INSTALL_ROOT}?"; then if ! confirm_yes "确认卸载并删除 ${INSTALL_ROOT}?"; then
log "已取消卸载" log "已取消卸载"
@@ -37,7 +41,7 @@ main_uninstall() {
local stamp backup_dir local stamp backup_dir
stamp="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)" stamp="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)"
backup_dir="${BACKUP_ROOT}/pre-uninstall-${stamp}" backup_dir="${BACKUP_ROOT}/pre-uninstall-slot${INSTANCE_SLOT}-${stamp}"
mkdir -p "${backup_dir}" mkdir -p "${backup_dir}"
step "备份配置与数据" step "备份配置与数据"
@@ -49,7 +53,9 @@ main_uninstall() {
fi fi
{ {
echo "created_at=${stamp}" echo "created_at=${stamp}"
echo "instance_slot=${INSTANCE_SLOT}"
echo "install_root=${INSTALL_ROOT}" echo "install_root=${INSTALL_ROOT}"
echo "api_port=${API_PORT}"
echo "pm2_app=${PM2_APP_NAME}" echo "pm2_app=${PM2_APP_NAME}"
echo "action=rm_rf_install_root" echo "action=rm_rf_install_root"
} >"${backup_dir}/uninstall.manifest" } >"${backup_dir}/uninstall.manifest"
@@ -60,28 +66,23 @@ main_uninstall() {
step "删除安装目录 ${INSTALL_ROOT}" step "删除安装目录 ${INSTALL_ROOT}"
if [[ -d "${INSTALL_ROOT}" ]]; then if [[ -d "${INSTALL_ROOT}" ]]; then
# 安全:只允许删除约定安装路径(默认 /opt/eth_hedge_sim if is_allowed_install_root "${INSTALL_ROOT}"; then
case "${INSTALL_ROOT}" in rm -rf "${INSTALL_ROOT}"
/opt/eth_hedge_sim|/opt/eth_hedge_sim/) log "已删除: ${INSTALL_ROOT}"
rm -rf "${INSTALL_ROOT}" elif [[ "${ALLOW_UNSAFE_UNINSTALL:-}" == "1" ]]; then
log "已删除: ${INSTALL_ROOT}" rm -rf "${INSTALL_ROOT}"
;; log "已删除(ALLOW_UNSAFE_UNINSTALL=1): ${INSTALL_ROOT}"
*) else
if [[ "${ALLOW_UNSAFE_UNINSTALL:-}" == "1" ]]; then die "拒绝删除非约定路径 ${INSTALL_ROOT};允许: /opt/eth_hedge_sim[_2|_3];或 ALLOW_UNSAFE_UNINSTALL=1"
rm -rf "${INSTALL_ROOT}" fi
log "已删除(ALLOW_UNSAFE_UNINSTALL=1): ${INSTALL_ROOT}"
else
die "拒绝删除非默认路径 ${INSTALL_ROOT};若确认,设置 ALLOW_UNSAFE_UNINSTALL=1"
fi
;;
esac
else else
log "安装目录不存在,跳过删除" log "安装目录不存在,跳过删除"
fi fi
# 清理历史「移走未删」残留 # 清理历史「移走未删」残留(仅当前槽位前缀)
local leftover local leftover base
for leftover in /opt/eth_hedge_sim.removed.* /opt/eth_hedge_sim.old.*; do base="${INSTALL_ROOT}"
for leftover in "${base}.removed."* "${base}.old."*; do
if [[ -e "${leftover}" ]]; then if [[ -e "${leftover}" ]]; then
rm -rf "${leftover}" rm -rf "${leftover}"
log "已清理残留: ${leftover}" log "已清理残留: ${leftover}"
@@ -90,11 +91,13 @@ main_uninstall() {
echo "" echo ""
echo "卸载完成." echo "卸载完成."
echo " 槽位: ${INSTANCE_SLOT}"
echo " 配置/数据备份: ${backup_dir}" echo " 配置/数据备份: ${backup_dir}"
echo " 安装目录已删除: ${INSTALL_ROOT}" echo " 安装目录已删除: ${INSTALL_ROOT}"
echo "" echo ""
echo "重新部署:" echo "重新部署该槽位:"
echo " curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/manage.sh | bash" echo " bash /opt/eth_hedge_sim/deploy/manage.sh # 选 1 → 槽位 ${INSTANCE_SLOT}"
echo " 或: INSTANCE_SLOT=${INSTANCE_SLOT} SKIP_SLOT_PROMPT=1 bash …/deploy/lib/install.sh"
} }
main_uninstall "$@" main_uninstall "$@"
+42 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy/lib/update.sh — 更新(仅 git pull,禁止 scp # deploy/lib/update.sh — 更新(仅 git pull,禁止 scp;支持单槽位或全部串行
set -e set -e
set -u set -u
if [ -n "${BASH_VERSION:-}" ]; then if [ -n "${BASH_VERSION:-}" ]; then
@@ -10,22 +10,58 @@ LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh # shellcheck source=common.sh
source "${LIB_DIR}/common.sh" source "${LIB_DIR}/common.sh"
update_one_slot() {
local slot="$1"
resolve_slot "${slot}"
if ! repo_ready "${INSTALL_ROOT}"; then
log "槽位 ${slot} 未安装(${INSTALL_ROOT}),跳过"
return 0
fi
step "更新槽位 ${slot}: ${INSTALL_ROOT} :${API_PORT} ${PM2_APP_NAME}"
ensure_system_deps
bash "${INSTALL_ROOT}/deploy/pull_and_restart.sh"
verify_health "${API_PORT}" || true
log "槽位 ${slot} 更新完成"
}
update_all_slots() {
local s
local any=0
step "串行更新全部已安装策略机槽位(避免同机并行 npm build"
for s in $(list_installed_slots); do
any=1
update_one_slot "${s}"
done
if [[ "${any}" -eq 0 ]]; then
die "未找到已安装的策略机(槽位 1/2/3)"
fi
echo ""
log "全部槽位更新完成"
}
main_update() { main_update() {
require_root require_root
local mode="${EHS_UPDATE_MODE:-one}"
if [[ "${mode}" == "all" ]]; then
update_all_slots
return 0
fi
resolve_slot "${INSTANCE_SLOT:-1}"
if ! REPO_ROOT="$(resolve_repo_root)"; then if ! REPO_ROOT="$(resolve_repo_root)"; then
die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署策略机」" die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署策略机」并选择该槽位"
fi fi
if ! repo_ready "${REPO_ROOT}"; then if ! repo_ready "${REPO_ROOT}"; then
die "安装不完整,请先执行「1) 一键部署策略机」" die "安装不完整: ${REPO_ROOT}"
fi fi
step "更新 — 环境检测 (缺则装,有则跳过)" step "更新 — 环境检测 (缺则装,有则跳过)"
ensure_system_deps ensure_system_deps
step "更新 比特骆驼自动化对冲系统 (git pull + 构建 + pm2 reload)" step "更新槽位 ${INSTANCE_SLOT} (git pull + 构建 + pm2 reload)"
bash "${REPO_ROOT}/deploy/pull_and_restart.sh" bash "${REPO_ROOT}/deploy/pull_and_restart.sh"
verify_health || true verify_health "${API_PORT}" || true
echo "" echo ""
log "更新完成" log "更新完成 slot=${INSTANCE_SLOT} http://127.0.0.1:${API_PORT}/health"
} }
main_update "$@" main_update "$@"
+51 -6
View File
@@ -182,13 +182,15 @@ show_banner() {
} }
show_menu() { show_menu() {
echo " 1) 一键部署策略机" echo " 1) 一键部署策略机(可选槽位 1/2/3"
echo " 2) 一键部署中控机" echo " 2) 一键部署中控机"
echo " 3) 一键卸载" echo " 3) 一键卸载策略机(选槽位)"
echo " 4) 更新策略机" echo " 4) 更新策略机(选槽位 / 全部)"
echo " 5) 更新中控机" echo " 5) 更新中控机"
echo " 0) 退出" echo " 0) 退出"
echo "" echo ""
echo " 槽位: 1→:5155 2→:5156 3→:5157(同机最多三套,实盘请只用 1)"
echo ""
} }
# 交互读入(兼容 curl|bash # 交互读入(兼容 curl|bash
@@ -224,6 +226,42 @@ deploy_control_node() {
log "中控部署完成 → http://<本机IP>:5160 默认账号 admin / admin123" log "中控部署完成 → http://<本机IP>:5160 默认账号 admin / admin123"
} }
run_with_slot_env() {
# 子脚本继承当前槽位变量
env \
INSTANCE_SLOT="${INSTANCE_SLOT}" \
INSTALL_ROOT="${INSTALL_ROOT}" \
API_PORT="${API_PORT}" \
PM2_APP_NAME="${PM2_APP_NAME}" \
SKIP_SLOT_PROMPT=1 \
EHS_FROM_MANAGE=1 \
"$@"
}
prompt_update_target() {
local line=""
echo ""
echo "更新目标:"
echo " 1/2/3) 指定槽位"
echo " a) 全部已安装槽位(串行,适合 2G 测机)"
cm_read line "请选择 [1/2/3/a,默认1]: "
line="${line:-1}"
case "${line}" in
a|A|all|ALL)
EHS_UPDATE_MODE=all
;;
1|2|3)
EHS_UPDATE_MODE=one
resolve_slot "${line}"
;;
*)
echo "无效选择,使用槽位 1"
EHS_UPDATE_MODE=one
resolve_slot 1
;;
esac
}
main_menu() { main_menu() {
bootstrap_repo bootstrap_repo
# shellcheck source=lib/common.sh # shellcheck source=lib/common.sh
@@ -237,16 +275,23 @@ main_menu() {
cm_read choice "请选择 [0-5]: " cm_read choice "请选择 [0-5]: "
case "${choice}" in case "${choice}" in
1) 1)
bash "${LIB_DIR}/install.sh" prompt_instance_slot
run_with_slot_env bash "${LIB_DIR}/install.sh"
;; ;;
2) 2)
deploy_control_node deploy_control_node
;; ;;
3) 3)
bash "${LIB_DIR}/uninstall.sh" prompt_instance_slot
run_with_slot_env bash "${LIB_DIR}/uninstall.sh"
;; ;;
4) 4)
bash "${LIB_DIR}/update.sh" prompt_update_target
if [[ "${EHS_UPDATE_MODE}" == "all" ]]; then
env EHS_UPDATE_MODE=all SKIP_SLOT_PROMPT=1 bash "${LIB_DIR}/update.sh"
else
run_with_slot_env bash "${LIB_DIR}/update.sh"
fi
;; ;;
5) 5)
deploy_control_node deploy_control_node
+32 -11
View File
@@ -2,41 +2,57 @@
# 一键更新/部署:仅 git pull,禁止 scp 传代码。 # 一键更新/部署:仅 git pull,禁止 scp 传代码。
# 用法: # 用法:
# bash /opt/eth_hedge_sim/deploy/pull_and_restart.sh # bash /opt/eth_hedge_sim/deploy/pull_and_restart.sh
# 或通过 manage.sh 选 1/3 # bash /opt/eth_hedge_sim_2/deploy/pull_and_restart.sh
# 或通过 manage.sh 选槽位后部署/更新
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
# 有 root 时:检测 Python / Node / PM2,缺则装、有则跳过(pull 前用当前脚本) _COMMON="$ROOT/deploy/lib/common.sh"
if [[ -f "$ROOT/deploy/lib/common.sh" ]] && [[ "$(id -u)" -eq 0 ]]; then if [[ -f "$_COMMON" ]]; then
# shellcheck source=lib/common.sh # shellcheck source=lib/common.sh
source "$ROOT/deploy/lib/common.sh" source "$_COMMON"
apply_slot_for_root "$ROOT"
fi
# 有 root 时:检测 Python / Node / PM2,缺则装、有则跳过(pull 前用当前脚本)
if [[ -f "$_COMMON" ]] && [[ "$(id -u)" -eq 0 ]]; then
ensure_system_deps ensure_system_deps
fi fi
echo "[比特骆驼] pull @ $ROOT" echo "[比特骆驼] pull @ $ROOT (slot=${INSTANCE_SLOT:-?} port=${API_PORT:-?})"
if [[ -d "$ROOT/.git" ]]; then if [[ -d "$ROOT/.git" ]]; then
git fetch --all --prune git fetch --all --prune
git pull --ff-only git pull --ff-only
fi fi
# pull 后再检测一次,确保用上仓库里最新的 python3-venv / ensurepip 逻辑 # pull 后再检测一次,确保用上仓库里最新的 python3-venv / ensurepip 逻辑
if [[ -f "$ROOT/deploy/lib/common.sh" ]] && [[ "$(id -u)" -eq 0 ]]; then if [[ -f "$_COMMON" ]]; then
# shellcheck source=lib/common.sh # shellcheck source=lib/common.sh
source "$ROOT/deploy/lib/common.sh" source "$_COMMON"
apply_slot_for_root "$ROOT"
fi
if [[ -f "$_COMMON" ]] && [[ "$(id -u)" -eq 0 ]]; then
ensure_system_deps ensure_system_deps
fi fi
if [[ ! -f "$ROOT/.env" ]]; then if [[ ! -f "$ROOT/.env" ]]; then
echo "[比特骆驼] missing .env — copy from example" echo "[比特骆驼] missing .env — copy from example"
cp "$ROOT/.env.example" "$ROOT/.env" cp "$ROOT/.env.example" "$ROOT/.env"
# 云上测试机默认直连 OKX # 云上测试机默认直连 OKX;端口按槽位
local_port="${API_PORT:-5155}"
sed -i 's/^OKX_HTTP_PROXY=.*/OKX_HTTP_PROXY=/' "$ROOT/.env" || true sed -i 's/^OKX_HTTP_PROXY=.*/OKX_HTTP_PROXY=/' "$ROOT/.env" || true
sed -i 's/^API_PORT=.*/API_PORT=5155/' "$ROOT/.env" || true sed -i "s/^API_PORT=.*/API_PORT=${local_port}/" "$ROOT/.env" || true
sed -i 's/^ENV_NAME=.*/ENV_NAME=test/' "$ROOT/.env" || true sed -i 's/^ENV_NAME=.*/ENV_NAME=test/' "$ROOT/.env" || true
sed -i 's/^EXCHANGE=.*/EXCHANGE=okx/' "$ROOT/.env" || true sed -i 's/^EXCHANGE=.*/EXCHANGE=okx/' "$ROOT/.env" || true
echo "[比特骆驼] wrote .env — please review AUTH_* secrets" if [[ -f "$_COMMON" ]]; then
secret="$(random_auth_secret)"
if [[ -n "${secret}" ]]; then
sed -i "s/^AUTH_SECRET=.*/AUTH_SECRET=${secret}/" "$ROOT/.env" || true
fi
fi
echo "[比特骆驼] wrote .env — API_PORT=${local_port};请按需修改 AUTH_*"
fi fi
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
@@ -93,8 +109,13 @@ else
echo "[比特骆驼] pm2 already present, skip install" echo "[比特骆驼] pm2 already present, skip install"
fi fi
# 按本目录槽位重写 PM2 配置(避免 git pull 盖回槽位 1 硬编码)
if [[ -f "$_COMMON" ]]; then
write_ecosystem_config "$ROOT" "${PM2_APP_NAME}" "${API_PORT}"
fi
# 仅 reload 本项目进程,禁止 pm2 restart all # 仅 reload 本项目进程,禁止 pm2 restart all
pm2 startOrReload "$ROOT/deploy/ecosystem.config.cjs" --update-env pm2 startOrReload "$ROOT/deploy/ecosystem.config.cjs" --update-env
pm2 save pm2 save
echo "[比特骆驼] done. health: http://127.0.0.1:5155/health" echo "[比特骆驼] done. slot=${INSTANCE_SLOT:-1} health: http://127.0.0.1:${API_PORT:-5155}/health"
+168 -147
View File
@@ -1,147 +1,168 @@
# 中控(Fleet Control)使用说明 # 中控(Fleet Control)使用说明
中控与策略机在**同一仓库**`eth_hedge_sim`),**独立进程**部署。中控通常跑在**本地局域网服务器**,**不访问交易所**;只通过专用 API Token 对多台策略机做:状态监控、启停、远程更新代码、免密进入策略页。 中控与策略机在**同一仓库**`eth_hedge_sim`),**独立进程**部署。中控通常跑在**本地局域网服务器**,**不访问交易所**;只通过专用 API Token 对多台策略机做:状态监控、启停、远程更新代码、免密进入策略页。
建议策略机与中控使用**同一 git 版本**(同 `main` / 同 tag),避免 `/api/fleet/*` 接口漂移。 建议策略机与中控使用**同一 git 版本**(同 `main` / 同 tag),避免 `/api/fleet/*` 接口漂移。
--- ---
## 1. 架构 ## 1. 架构
```text ```text
本地中控 (5160) 云端策略机 × N (5155) 本地中控 (5160) 云端策略机 × N (5155)
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ control 前端/后端 │──X-Fleet-Token──│ eth-hedge-api │ │ control 前端/后端 │──X-Fleet-Token──│ eth-hedge-api │
│ 不连交易所 │──SSH 无需主路径──│ OKX/币安交易密钥 │ │ 不连交易所 │──SSH 无需主路径──│ OKX/币安交易密钥 │
└─────────────────┘ └──────────────────┘ └─────────────────┘ └──────────────────┘
``` ```
| 组件 | 端口 | PM2 名 | 代码目录 | 配置 | | 组件 | 端口 | PM2 名 | 代码目录 | 配置 |
|------|------|--------|----------|------| |------|------|--------|----------|------|
| 策略机 | 5155 | `eth-hedge-api` | `backend/` + `frontend/` | 仓库根 `.env` | | 策略机(槽位 1 | 5155 | `eth-hedge-api` | `/opt/eth_hedge_sim` | 该目录 `.env` |
| 中控 | 5160 | `eth-hedge-control` | `control/backend` + `control/frontend` | 仓库根 `.env.control` | | 策略机(槽位 2,可选) | 5156 | `eth-hedge-api-2` | `/opt/eth_hedge_sim_2` | 独立 `.env` / SQLite |
| 策略机(槽位 3,可选) | 5157 | `eth-hedge-api-3` | `/opt/eth_hedge_sim_3` | 独立 `.env` / SQLite |
中控浏览器**不直连**策略机 API(避免跨域与密钥进前端);一律由中控后端代理。 | 中控 | 5160 | `eth-hedge-control` | `control/backend` + `control/frontend` | 仓库根 `.env.control` |
--- 中控浏览器**不直连**策略机 API(避免跨域与密钥进前端);一律由中控后端代理。
## 2. 一键部署(manage.sh ### 同机多套策略机(测试用
目标机 Ubuntu 22.04 - **实盘**:一机一套,只用**槽位 1**。
- **测试**:同一台机可再装槽位 2/3(完整独立克隆 + 独立进程),菜单选槽位即可;端口/PM2 名自动分配。
```bash - 中控登记多套时,每套一条「策略机」:Base URL 带不同端口,例如 `http://x.x.x.x:5155``:5156``:5157`(或各自反代域名)。
curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/manage.sh | bash - 更新「全部」为**串行**(避免 2G 内存并行 `npm build`)。
```
| 槽位 | 目录 | 端口 | PM2 |
交互菜单: |------|------|------|-----|
| 1 | `/opt/eth_hedge_sim` | 5155 | `eth-hedge-api` |
| 选项 | 作用 | | 2 | `/opt/eth_hedge_sim_2` | 5156 | `eth-hedge-api-2` |
|------|------| | 3 | `/opt/eth_hedge_sim_3` | 5157 | `eth-hedge-api-3` |
| 1) 一键部署策略机 | 克隆/构建,启动 `eth-hedge-api` :5155 |
| 2) 一键部署中控机 | 构建中控,启动 `eth-hedge-control` :5160;自动补全 `.env.control`**已有非空值不覆盖** | 非交互示例:
| 3) 一键卸载 | 卸载策略相关(按现有 uninstall 逻辑) |
| 4) 更新策略机 | `git pull` + 构建 + reload 策略机 | ```bash
| 5) 更新中控机 | `control/deploy/update.sh` | INSTANCE_SLOT=2 SKIP_SLOT_PROMPT=1 bash /opt/eth_hedge_sim/deploy/lib/install.sh
| 0) 退出 | — | ```
也可本机已安装后: ---
```bash ## 2. 一键部署(manage.sh
bash /opt/eth_hedge_sim/deploy/manage.sh
# 或仅更新中控 目标机 Ubuntu 22.04
bash /opt/eth_hedge_sim/control/deploy/update.sh
# 开发机 SSH 触发中控(可选) ```bash
# CONTROL_HOST=... CONTROL_PASS=... python scripts/deploy_control.py curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/manage.sh | bash
``` ```
--- 交互菜单:
## 3. 中控登录与系统设置 | 选项 | 作用 |
|------|------|
### 3.1 默认账号 | 1) 一键部署策略机 | 选槽位 1/2/3 → 克隆/构建,启动对应端口 |
| 2) 一键部署中控机 | 构建中控,启动 `eth-hedge-control` :5160;自动补全 `.env.control`**已有非空值不覆盖** |
- 首次:`admin` / `admin123`(一键部署写入 `.env.control`,已有值不覆盖)。 | 3) 一键卸载 | 选槽位,只卸该套(不影响其它槽位) |
- **改密后**:登录页与设置页**不再显示**默认账号提示。 | 4) 更新策略机 | 选槽位,或 `a` 全部串行更新 |
- 登录框**不预填用户名**,并关闭浏览器自动填充。 | 5) 更新中控机 | `control/deploy/update.sh` |
| 0) 退出 | — |
### 3.2 系统设置(三个 Tab
也可本机已安装后:
1. **中控登录账户**
- 修改用户名/密码(改后旧会话作废)。 ```bash
- **本地局域网免登录**:开关;开启后,从私网 IP(如 `192.168.x` / `10.x` / `172.1631.x` / 本机)访问可自动进入监控区,无需输密码。 bash /opt/eth_hedge_sim/deploy/manage.sh
2. **添加策略机** — 名称 + 公网 Base URL(如 `https://dc.hyf2.cc`)。 # 或仅更新中控
3. **策略机列表** — 生成/轮换 Token、删除。 bash /opt/eth_hedge_sim/control/deploy/update.sh
# 开发机 SSH 触发中控(可选)
### 3.3 `.env.control` 常用项 # CONTROL_HOST=... CONTROL_PASS=... python scripts/deploy_control.py
```
见仓库 [`.env.control.example`](../.env.control.example)
---
| 变量 | 说明 |
|------|------| ## 3. 中控登录与系统设置
| `CONTROL_AUTH_USERNAME` / `PASSWORD` | 中控登录 |
| `CONTROL_AUTH_SECRET` | Token 加密与签名密钥(部署自动填默认值,有值不覆盖) | ### 3.1 默认账号
| `CONTROL_LAN_AUTH_BYPASS` | `1`=局域网免登录,`0`=关闭 |
| `CONTROL_SSE_INTERVAL_SEC` | 监控 SSE 推送间隔(默认 1 秒) | - 首次:`admin` / `admin123`(一键部署写入 `.env.control`,已有值不覆盖)。
| `CONTROL_SSE_PROBE_TIMEOUT_SEC` | 单次探测策略机超时(默认 2.5 秒) | - **改密后**:登录页与设置页**不再显示**默认账号提示。
| `CONTROL_POLL_INTERVAL_SEC` | 兼容保留(旧前端轮询;现以 SSE 为主) | - 登录框**不预填用户名**,并关闭浏览器自动填充。
**勿提交** `.env.control` 到 git。 ### 3.2 系统设置(三个 Tab
--- 1. **中控登录账户**
- 修改用户名/密码(改后旧会话作废)。
## 4. 策略机配对(API Token - **本地局域网免登录**:开关;开启后,从私网 IP(如 `192.168.x` / `10.x` / `172.1631.x` / 本机)访问可自动进入监控区,无需输密码。
2. **添加策略机** — 名称 + 公网 Base URL(如 `https://dc.hyf2.cc`)。
1. 中控 **策略机列表** **生成 Token**(明文只显示一次,中控侧加密保存)。 3. **策略机列表** — 生成/轮换 Token、删除。
2. 登录该策略机 → **系统设置 → 登录账户 → 中控 API Token** → 粘贴保存(策略机存哈希)。
3. 中控监控区显示「已配对」;若 Token 不一致则「配对失败」并提示重新生成保存。 ### 3.3 `.env.control` 常用项
### 策略机 Fleet 接口(头:`X-Fleet-Token`,也可 `Authorization: Fleet <token>` 见仓库 [`.env.control.example`](../.env.control.example)
| 方法 | 路径 | 作用 | | 变量 | 说明 |
|------|------|------| |------|------|
| GET | `/api/fleet/status` | 状态摘要 + 持仓/策略详情 | | `CONTROL_AUTH_USERNAME` / `PASSWORD` | 中控登录 |
| POST | `/api/fleet/start` | 启动策略(LIVE 仍过策略机门禁 | | `CONTROL_AUTH_SECRET` | Token 加密与签名密钥(部署自动填默认值,有值不覆盖 |
| POST | `/api/fleet/pause` | 停止/暂停 | | `CONTROL_LAN_AUTH_BYPASS` | `1`=局域网免登录,`0`=关闭 |
| POST | `/api/fleet/update` | 本机 `deploy/lib/update.sh`reload,不自动 start | | `CONTROL_SSE_INTERVAL_SEC` | 监控 SSE 推送间隔(默认 1 秒 |
| POST | `/api/fleet/issue-login` | 签发一次性免密登录票 | | `CONTROL_SSE_PROBE_TIMEOUT_SEC` | 单次探测策略机超时(默认 2.5 秒) |
| `CONTROL_POLL_INTERVAL_SEC` | 兼容保留(旧前端轮询;现以 SSE 为主) |
免密进策略页:中控拿到 ticket 后打开
`https://<策略机>/fleet-login?ticket=...` **勿提交** `.env.control` 到 git。
`POST /api/auth/fleet-exchange` 兑换普通会话 → `/plan`
票约 **60 秒、一次性**;长期 Token **不进**浏览器地址栏。 ---
--- ## 4. 策略机配对(API Token
## 5. 监控区 1. 中控 **策略机列表****生成 Token**(明文只显示一次,中控侧加密保存)。
2. 登录该策略机 → **系统设置 → 登录账户 → 中控 API Token** → 粘贴保存(策略机存哈希)。
- **实时推送**:浏览器通过 SSE(`GET /api/nodes/status/stream`)接收状态;中控约每秒**并行**拉取各策略机 `/api/fleet/status`,有变化才推送;断线自动重连。工具栏显示「实时 · 1s」 3. 中控监控区显示「已配对」;若 Token 不一致则「配对失败」并提示重新生成保存
- **刷新**:仍可手动走 `/api/nodes/status/all` 拉一次。
- **卡片**:在线/离线、SIM/LIVE、阶段、持仓、轮次、行情、Token 状态。 ### 策略机 Fleet 接口(头:`X-Fleet-Token`,也可 `Authorization: Fleet <token>`
- **运行中**:卡片绿色;底部按钮显示「运行中」且不可点启动。
- **点击卡片**:放大弹层 — 策略详情 + 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。 | 方法 | 路径 | 作用 |
- **登录策略机**:免密新标签打开策略页。 |------|------|------|
- **更新代码 / 勾选更新**:**二次确认**后执行;会 reload 进程,**不会**自动 start。 | GET | `/api/fleet/status` | 状态摘要 + 持仓/策略详情 |
| POST | `/api/fleet/start` | 启动策略(LIVE 仍过策略机门禁) |
中控**不做**:改策略参数、代开平仓、资金划转、直连交易所。 | POST | `/api/fleet/pause` | 停止/暂停 |
| POST | `/api/fleet/update` | 本机 `deploy/lib/update.sh`reload,不自动 start |
--- | POST | `/api/fleet/issue-login` | 签发一次性免密登录票 |
## 6. 安全边界 免密进策略页:中控拿到 ticket 后打开
`https://<策略机>/fleet-login?ticket=...`
- 中控无交易所 API Key;交易密钥只在各策略机 `.env` `POST /api/auth/fleet-exchange` 兑换普通会话 → `/plan`
- Fleet Token 权限含启停/更新/签发登录票 → 泄露后两边立即轮换。 票约 **60 秒、一次性**;长期 Token **不进**浏览器地址栏。
- 局域网免登录仅私网 IP;勿对公网暴露中控端口。
- 策略机 LIVE 启停仍走本机 `live_ready()` 等门禁,中控不能绕过。 ---
--- ## 5. 监控区
## 7. 相关文档与入口 - **实时推送**:浏览器通过 SSE(`GET /api/nodes/status/stream`)接收状态;中控约每秒**并行**拉取各策略机 `/api/fleet/status`,有变化才推送;断线自动重连。工具栏显示「实时 · 1s」。
- **刷新**:仍可手动走 `/api/nodes/status/all` 拉一次。
| 文档 | 内容 | - **卡片**:在线/离线、SIM/LIVE、阶段、持仓、轮次、行情、Token 状态。
|------|------| - **运行中**:卡片绿色;底部按钮显示「运行中」且不可点启动。
| 本文 | 中控安装、配对、监控、安全 | - **点击卡片**:放大弹层 — 策略详情 + 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。
| [更新说明](./更新说明.md) | 发版变更记录 | - **登录策略机**:免密新标签打开策略页。
| [代码结构](./代码结构.md) | 仓库目录(含 `control/` | - **更新代码 / 勾选更新**:**二次确认**后执行;会 reload 进程,**不会**自动 start。
| [README](../README.md) | 总览与部署入口 |
中控**不做**:改策略参数、代开平仓、资金划转、直连交易所。
产品文档索引(README「文档」一节)含本页链接。
---
## 6. 安全边界
- 中控无交易所 API Key;交易密钥只在各策略机 `.env`
- Fleet Token 权限含启停/更新/签发登录票 → 泄露后两边立即轮换。
- 局域网免登录仅私网 IP;勿对公网暴露中控端口。
- 策略机 LIVE 启停仍走本机 `live_ready()` 等门禁,中控不能绕过。
---
## 7. 相关文档与入口
| 文档 | 内容 |
|------|------|
| 本文 | 中控安装、配对、监控、安全 |
| [更新说明](./更新说明.md) | 发版变更记录 |
| [代码结构](./代码结构.md) | 仓库目录(含 `control/` |
| [README](../README.md) | 总览与部署入口 |
产品文档索引(README「文档」一节)含本页链接。