物理修复(400 失败点归因,见 docs/failed400_nan_pseudo_convergence_2026_08_17.md): - runner: 阶段 converged 后复查 fort.7,含 NaN/Inf 即否决(fort.9 全零伪收敛, 曾致 261 点误跳过 nl_direct 回退);否决阶段不产出种子,阻断污染传播 - runner: nl_tight 回退——仅能量边际失败时以 CHMAX 收紧 10× 从自身模型续迭代, 残差降幅 ~10×;execute_tlusty_stage 抽取供主链与回退共用 - conv_check: fort.14 为 Eddington 通量 Hλ,积分需乘 4π 再比 σTeff⁴ (旧版 ratio 稳定 0.0796=1/4π,全点系统性假阳性)+ 回归测试 - nst_writer: 单行超 80 字符被 TLUSTY 静默截断,IFALI/JALI/TRAD 等从未生效; 按 75 字符自动换行 - seed_finder: Teff 容忍度改含边界 <=,相邻 5000K 档恢复互为种子 + 回归测试 谱线表与网格: - 默认线表 gfVIS99 → gfATO(全波段 18-23000Å),TaskSpec.linelist 支持工作流 级覆盖,节点按需下载(进程互斥锁防并发重复下载 238MB) - sdB_cno Teff 加密至 5000K 步长,432 → 9216 点;tlusty/synspec 静态二进制更新 统计与部署: - grid 汇总改按 tlusty_status/synspec_status 分项计数,新增 tlusty_failed/ synspec_failed/synspec_pending,前端详情页双视图适配 - deploy/fetch_results 支持跳板机 ProxyJump 与 SSH 主连接复用,fetch 新增 --force; - Docker 构建支持 CARGO_MIRROR/USE_MIRRORS 国内镜像参数;移除 tools/ 拷贝 - 新增 tlusty-synspec-test skill 与 6 篇根因分析/验证文档
1046 lines
45 KiB
Bash
Executable File
1046 lines
45 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ==============================================================================
|
||
# deploy.sh — DCTS 统一部署与管理控制台
|
||
# ==============================================================================
|
||
# 支持交互式向导及命令行参数化调用(适用于 CI/CD 自动化运维)。
|
||
#
|
||
# 常用示例:
|
||
# ./scripts/deploy.sh # 交互式向导
|
||
# ./scripts/deploy.sh -e local -b compose -r all # 本地 + Docker Compose + 部署全栈
|
||
# ./scripts/deploy.sh -e remote -b systemd -r server # 远程 + Systemd + 仅部署服务端
|
||
# ./scripts/deploy.sh remove -e local -b systemd -r all # 停止并卸载本地 Systemd 服务
|
||
# ./scripts/deploy.sh -e remote -b compose -r all --skip-assets # 跳过静态资源同步,仅更新程序代码
|
||
# ==============================================================================
|
||
|
||
set -euo pipefail
|
||
|
||
GREEN='\033[0;32m'
|
||
BLUE='\033[0;34m'
|
||
RED='\033[0;31m'
|
||
YELLOW='\033[1;33m'
|
||
CYAN='\033[0;36m'
|
||
MAGENTA='\033[0;35m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# 定位至项目根目录
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
WORK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||
cd "${WORK_DIR}"
|
||
|
||
# =============================================================================
|
||
# 默认配置与环境变量
|
||
# =============================================================================
|
||
REMOTE_USER="${REMOTE_USER:-fmq}"
|
||
REMOTE_IP="${REMOTE_IP:-100.66.1.2}"
|
||
REMOTE_PORT="${REMOTE_PORT:-22}"
|
||
REMOTE_DIR="${REMOTE_DIR:-/vol1/1000/program/dcts}"
|
||
|
||
# SSH ControlMaster 连接复用目录 (全局统一,使父子/兄弟进程共享 socket)
|
||
SSH_CONTROL_DIR="${SSH_CONTROL_DIR:-$HOME/.ssh/cm}"
|
||
|
||
ENV_MODE="${DEPLOY_ENV:-}"
|
||
BACKEND_MODE="${DEPLOY_BACKEND:-}"
|
||
ROLE_TARGET="${DEPLOY_ROLE:-}"
|
||
PROFILE_FILE=""
|
||
PROFILE_LOADED=""
|
||
PASSED_CLI_ARGS=("$@")
|
||
|
||
# 在 set -u 下,以下"可选/按需出现"的变量若从未赋值,引用即报错。这里统一预置
|
||
# 空初值,保留"笔误即报错"的语义(真正的拼写错误仍会在 set -u 下被捕获),
|
||
# 仅对确实合法的可选变量放开。DEPLOY_* 来自可能未定义的 Profile/env,最易踩坑。
|
||
ACTION=""
|
||
PROFILE_ARG=""
|
||
CLI_ENV_SET=""
|
||
CLI_BACKEND_SET=""
|
||
CLI_ROLE_SET=""
|
||
CLI_ASSETS_SET=""
|
||
SYNC_ASSETS=""
|
||
# Profile 中可能定义的运行期变量(DCTS_*/LOG_*),write_remote_runtime_env 会读取。
|
||
DEPLOY_ENV="${DEPLOY_ENV:-}"
|
||
DEPLOY_BACKEND="${DEPLOY_BACKEND:-}"
|
||
DEPLOY_ROLE="${DEPLOY_ROLE:-}"
|
||
DCTS_NODE_ID="${DCTS_NODE_ID:-}"
|
||
DCTS_SERVER_URL="${DCTS_SERVER_URL:-}"
|
||
DCTS_MAX_SLOTS="${DCTS_MAX_SLOTS:-}"
|
||
DCTS_PORT="${DCTS_PORT:-}"
|
||
DCTS_ADMIN_TOKEN="${DCTS_ADMIN_TOKEN:-}"
|
||
DCTS_AUTH_DISABLE="${DCTS_AUTH_DISABLE:-}"
|
||
LOG_DIR="${LOG_DIR:-}"
|
||
DCTS_LOG="${DCTS_LOG:-}"
|
||
DCTS_HEARTBEAT_SEC="${DCTS_HEARTBEAT_SEC:-}"
|
||
# 远程 systemd 部署时父进程通过 env 传入的预构建镜像归档(可能为空)。
|
||
PREBUILT_TAR="${PREBUILT_TAR:-}"
|
||
# SSH 复用相关:setup_ssh_control() 中赋值,但 check_remote_is_windows 等函数会先读取,
|
||
# 预置空初值避免 set -u 下"使用前未定义"。
|
||
SSH_OPTS=""
|
||
SSH_CONTROL_PATH=""
|
||
SSH_REUSED_CONNECTION=""
|
||
|
||
load_profile_file() {
|
||
local pfile="$1"
|
||
if [ -f "$pfile" ]; then
|
||
echo -e "${CYAN}[i] 正在加载 Profile 配置文件: ${pfile}${NC}"
|
||
set -a
|
||
# shellcheck disable=SC1090
|
||
source "$pfile"
|
||
set +a
|
||
PROFILE_LOADED="$pfile"
|
||
# 优先级: 命令行显式参数 > Profile > 默认值。仅在命令行未指定时才采用 Profile 的值。
|
||
[ -z "${CLI_ENV_SET:-}" ] && [ -n "${DEPLOY_ENV}" ] && ENV_MODE="${DEPLOY_ENV}"
|
||
[ -z "${CLI_BACKEND_SET:-}" ] && [ -n "${DEPLOY_BACKEND}" ] && BACKEND_MODE="${DEPLOY_BACKEND}"
|
||
[ -z "${CLI_ROLE_SET:-}" ] && [ -n "${DEPLOY_ROLE}" ] && ROLE_TARGET="${DEPLOY_ROLE}"
|
||
if [ -z "${CLI_ASSETS_SET:-}" ]; then
|
||
[ -n "${SYNC_ASSETS}" ] || SYNC_ASSETS="auto"
|
||
fi
|
||
[ -n "${ACTION}" ] || ACTION="install"
|
||
return 0
|
||
else
|
||
echo -e "${RED}错误: 找不到 Profile 配置文件 '${pfile}'${NC}"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
write_remote_runtime_env() {
|
||
local is_win="$1"
|
||
local env_content=""
|
||
|
||
if [ -n "${PROFILE_LOADED}" ] && [ -f "${PROFILE_LOADED}" ]; then
|
||
env_content=$(grep -E '^(DCTS_|LOG_|RUST_LOG)' "${PROFILE_LOADED}" 2>/dev/null || echo "")
|
||
fi
|
||
|
||
local var_list=(
|
||
DCTS_NODE_ID
|
||
DCTS_SERVER_URL
|
||
DCTS_MAX_SLOTS
|
||
DCTS_PORT
|
||
DCTS_ADMIN_TOKEN
|
||
DCTS_AUTH_DISABLE
|
||
LOG_DIR
|
||
DCTS_LOG
|
||
DCTS_HEARTBEAT_SEC
|
||
)
|
||
|
||
for var in "${var_list[@]}"; do
|
||
if ! echo "${env_content}" | grep -q "^${var}=" 2>/dev/null; then
|
||
local val="${!var}"
|
||
if [ -n "${val}" ]; then
|
||
if [ -n "${env_content}" ]; then
|
||
env_content="${env_content}"$'\n'"${var}=${val}"
|
||
else
|
||
env_content="${var}=${val}"
|
||
fi
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [ -z "${env_content}" ]; then
|
||
env_content="DCTS_SERVER_URL=${DCTS_SERVER_URL:-http://100.66.1.2:8090}"$'\n'"DCTS_MAX_SLOTS=${DCTS_MAX_SLOTS:-4}"$'\n'"LOG_DIR=${LOG_DIR:-/app/data/logs}"
|
||
fi
|
||
|
||
echo "=== 在远端服务器直接生成专属 .env 配置文件 (无需 SCP 传输文件) ==="
|
||
# 统一通过 SSH stdin 管道写入 .env 内容。
|
||
# Windows 侧原用 PowerShell here-string (@'...'@),但其闭合标记 '@ 要求独占
|
||
# 行首,经 bash→SSH→cmd.exe→PowerShell 多层转义与换行重组后无法被识别,
|
||
# 报 "字符串缺少终止符: '@" (TerminatorExpectedAtEndOfString)。
|
||
# 改用 stdin 管道 + [Console]::In.ReadToEnd() 读取,命令字符串内不含任何
|
||
# 多行内容或 here-string,彻底规避该转义问题。
|
||
if [ "$is_win" = "true" ]; then
|
||
printf '%s\n' "${env_content}" | ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
|
||
"powershell -NoProfile -Command \"Set-Content -Path '${REMOTE_DIR}/.env' -Value ([Console]::In.ReadToEnd()) -Encoding utf8\""
|
||
else
|
||
printf '%s\n' "${env_content}" | ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" \
|
||
"cat > '${REMOTE_DIR}/.env' && chmod 600 '${REMOTE_DIR}/.env'"
|
||
fi
|
||
}
|
||
|
||
batch_deploy_profiles() {
|
||
local profiles=("$@")
|
||
# 批量部署面向多台远程主机,剔除本地 Profile (DEPLOY_ENV=local),避免无意义的
|
||
# SSH 预连接与本地构建混入远程批量流程。
|
||
local filtered_profiles=()
|
||
for p in "${profiles[@]}"; do
|
||
local p_env
|
||
p_env=$(grep -E '^DEPLOY_ENV=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||
if [ "$p_env" = "local" ]; then
|
||
echo -e "${YELLOW}[i] 已从批量部署中排除本地 Profile: ${p} (本地部署请单独执行)${NC}"
|
||
continue
|
||
fi
|
||
filtered_profiles+=("$p")
|
||
done
|
||
profiles=("${filtered_profiles[@]}")
|
||
local total=${#profiles[@]}
|
||
if [ "$total" -eq 0 ]; then
|
||
echo -e "${YELLOW}[i] 批量部署列表中无远程节点,已跳过。${NC}"
|
||
return 0
|
||
fi
|
||
|
||
echo -e "\n${GREEN}==============================================================${NC}"
|
||
echo -e "${GREEN}🚀 启动高效多节点批量部署引擎: 目标节点总数 [${total}]${NC}"
|
||
echo -e "${GREEN}==============================================================${NC}"
|
||
|
||
local need_server=false
|
||
local need_node=false
|
||
local need_all=false
|
||
local need_compose=false
|
||
local need_systemd=false
|
||
local p
|
||
|
||
for p in "${profiles[@]}"; do
|
||
local r_role
|
||
r_role=$(grep -E '^DEPLOY_ROLE=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "all")
|
||
local r_backend
|
||
r_backend=$(grep -E '^DEPLOY_BACKEND=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "compose")
|
||
|
||
case "$r_role" in
|
||
server) need_server=true ;;
|
||
node) need_node=true ;;
|
||
all|*) need_server=true; need_node=true; need_all=true ;;
|
||
esac
|
||
if [ "$r_backend" = "systemd" ]; then need_systemd=true; else need_compose=true; fi
|
||
done
|
||
|
||
# ─── 【阶段 0】提前为所有目标节点建立 SSH 主连接 ─────────────────────────────
|
||
# 在本地构建/打包之前完成所有密码输入,避免"构建→输密码→推送→输密码→推送"的割裂等待。
|
||
# 连接经 ControlPersist 长期保持,后续递归子进程通过共享 ControlPath 直接复用,无需重复认证。
|
||
echo -e "\n${CYAN}=== 【阶段 0/2】预建立所有目标节点 SSH 连接 (请在此时集中输入各节点密码) ===${NC}"
|
||
mkdir -p "${SSH_CONTROL_DIR}"
|
||
local pre_idx=0
|
||
for p in "${profiles[@]}"; do
|
||
pre_idx=$((pre_idx + 1))
|
||
local r_user r_ip r_port
|
||
r_user=$(grep -E '^REMOTE_USER=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_USER}")
|
||
r_ip=$(grep -E '^REMOTE_IP=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_IP}")
|
||
r_port=$(grep -E '^REMOTE_PORT=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_PORT}")
|
||
[ -z "$r_user" ] && r_user="${REMOTE_USER}"
|
||
[ -z "$r_ip" ] && r_ip="${REMOTE_IP}"
|
||
[ -z "$r_port" ] && r_port="${REMOTE_PORT}"
|
||
|
||
local sock="${SSH_CONTROL_DIR}/${r_user}@${r_ip}:${r_port}"
|
||
# 跳板机支持:profile 的 REMOTE_PROXY_JUMP(仅建立主连接时需要;-O check/exit 只操作本地 mux socket)
|
||
local r_jump jump_opt=""
|
||
r_jump=$(grep -E '^REMOTE_PROXY_JUMP=' "$p" 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" || true)
|
||
[ -n "${r_jump}" ] && jump_opt="-o ProxyJump=${r_jump}"
|
||
if ssh -p "${r_port}" -o ControlPath="${sock}" ${jump_opt} -O check "${r_user}@${r_ip}" >/dev/null 2>&1; then
|
||
echo " -> [${pre_idx}/${total}] ${r_user}@${r_ip}:${r_port} (连接已存在,复用)"
|
||
continue
|
||
fi
|
||
echo " -> [${pre_idx}/${total}] 正在连接 ${r_user}@${r_ip}:${r_port} ..."
|
||
ssh -p "${r_port}" -o ControlMaster=yes -o ControlPath="${sock}" -o ControlPersist=1800 ${jump_opt} -fN "${r_user}@${r_ip}" \
|
||
&& echo -e " ${GREEN}[√] 连接已建立${NC}" \
|
||
|| echo -e " ${YELLOW}[!] 连接失败,将在部署该节点时重试${NC}"
|
||
done
|
||
|
||
local build_services=""
|
||
if [ "$need_server" = true ] && [ "$need_node" = true ]; then
|
||
build_services="server node"
|
||
elif [ "$need_server" = true ]; then
|
||
build_services="server"
|
||
else
|
||
build_services="node"
|
||
fi
|
||
|
||
echo -e "${CYAN}=== 【阶段 1/2】本地全局单次构建与程序/镜像打包 [服务: ${build_services}] ===${NC}"
|
||
|
||
if [ "$need_compose" = true ]; then
|
||
echo " -> [Docker Compose] 统一构建镜像 (仅构建 1 次)..."
|
||
docker compose build ${build_services}
|
||
fi
|
||
|
||
if [ "$need_systemd" = true ]; then
|
||
echo " -> [Systemd] 统一编译 release 二进制程序 (仅编译 1 次)..."
|
||
cargo build --release --workspace
|
||
fi
|
||
|
||
# 按角色分别打包镜像 (server/node 各自独立),避免给单角色节点传输不需要的镜像。
|
||
local server_tar=""
|
||
local node_tar=""
|
||
if [ "$need_compose" = true ]; then
|
||
mkdir -p data
|
||
if [ "$need_server" = true ]; then
|
||
server_tar="data/.batch_dcts-server.tar.gz"
|
||
echo " -> [Docker Compose] 导出 server 镜像归档包: ${server_tar}..."
|
||
docker save dcts-server:latest | gzip > "${server_tar}"
|
||
fi
|
||
if [ "$need_node" = true ]; then
|
||
node_tar="data/.batch_dcts-node.tar.gz"
|
||
echo " -> [Docker Compose] 导出 node 镜像归档包: ${node_tar}..."
|
||
docker save dcts-node:latest | gzip > "${node_tar}"
|
||
fi
|
||
fi
|
||
|
||
echo -e "\n${CYAN}=== 【阶段 2/2】同步镜像并为各个节点定制生成专属 .env ===${NC}"
|
||
local idx=1
|
||
local all_ok=true
|
||
|
||
for p in "${profiles[@]}"; do
|
||
echo -e "\n${MAGENTA}--------------------------------------------------------------${NC}"
|
||
echo -e "${MAGENTA}▶▶▶ [节点 ${idx}/${total}] 正在部署 Profile: ${p}${NC}"
|
||
echo -e "${MAGENTA}--------------------------------------------------------------${NC}"
|
||
|
||
# 依据该 Profile 的角色选择对应的镜像归档包 (server/node 分开),all 角色传两个。
|
||
local p_role
|
||
p_role=$(grep -E '^DEPLOY_ROLE=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "all")
|
||
local selected_tar=""
|
||
case "$p_role" in
|
||
server) selected_tar="${server_tar}" ;;
|
||
node) selected_tar="${node_tar}" ;;
|
||
all|*) selected_tar="${server_tar} ${node_tar}" ;;
|
||
esac
|
||
|
||
if ! PREBUILT_TAR="${selected_tar}" bash "$0" -f "${p}"; then
|
||
echo -e "${RED}[✗] 节点 Profile '${p}' 部署失败!${NC}"
|
||
all_ok=false
|
||
else
|
||
echo -e "${GREEN}[√] 节点 Profile '${p}' 部署成功!${NC}"
|
||
fi
|
||
((idx++))
|
||
done
|
||
|
||
# 清理本阶段打包的临时镜像归档包
|
||
[ -n "${server_tar}" ] && rm -f "${server_tar}" 2>/dev/null || true
|
||
[ -n "${node_tar}" ] && rm -f "${node_tar}" 2>/dev/null || true
|
||
|
||
# 关闭阶段 0 预建立的所有 SSH 主连接
|
||
for p in "${profiles[@]}"; do
|
||
local cu cip cp2 csock
|
||
cu=$(grep -E '^REMOTE_USER=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_USER}")
|
||
cip=$(grep -E '^REMOTE_IP=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_IP}")
|
||
cp2=$(grep -E '^REMOTE_PORT=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "${REMOTE_PORT}")
|
||
[ -z "$cu" ] && cu="${REMOTE_USER}"; [ -z "$cip" ] && cip="${REMOTE_IP}"; [ -z "$cp2" ] && cp2="${REMOTE_PORT}"
|
||
csock="${SSH_CONTROL_DIR}/${cu}@${cip}:${cp2}"
|
||
ssh -p "${cp2}" -o ControlPath="${csock}" "${cu}@${cip}" -O exit 2>/dev/null || true
|
||
done
|
||
|
||
echo -e "\n${BLUE}==============================================================${NC}"
|
||
if [ "$all_ok" = true ]; then
|
||
echo -e "${GREEN}🎉 批量部署完成!所有 ${total} 个节点均已一次性更新并启动服务!${NC}"
|
||
else
|
||
echo -e "${YELLOW}⚠️ 批量部署结束,部分节点出现错误,请检查日志。${NC}"
|
||
exit 1
|
||
fi
|
||
exit 0
|
||
}
|
||
|
||
show_help() {
|
||
echo -e "${BLUE}DCTS 部署与管理控制台${NC}"
|
||
echo ""
|
||
echo "用法: $0 [动作] [选项]"
|
||
echo ""
|
||
echo "动作 (Actions):"
|
||
echo " install 安装或更新服务 (默认)"
|
||
echo " remove | clean 停止并卸载指定服务"
|
||
echo ""
|
||
echo "选项 (Options):"
|
||
echo " -f, --profile <file|dir|list> 指定节点 Profile (例: deploy.env.d/node1.env, 或 node1,node2, 或 deploy.env.d/)"
|
||
echo " --all-profiles 批量部署 deploy.env.d/ 下的所有生效 Profile"
|
||
echo " -e, --env <local|remote> 部署环境: local (本地) 或 remote (远程)"
|
||
echo " -b, --backend <compose|systemd> 底层平台: compose (Docker Compose) 或 systemd (Systemd 服务)"
|
||
echo " -r, --role <all|server|node> 目标模块: all (全量), server (服务端), node (计算节点)"
|
||
echo " --skip-assets 跳过 assets/ 静态资源同步,仅更新程序"
|
||
echo " --force-assets 强制重新全量同步 assets/ 静态资源"
|
||
echo " -h, --help 显示帮助信息"
|
||
echo ""
|
||
echo "示例: $0 -f deploy.env.d/node-win-01.env -e remote -b compose -r node"
|
||
exit 0
|
||
}
|
||
|
||
# =============================================================================
|
||
# 1. 命令行参数解析
|
||
# =============================================================================
|
||
while [[ $# -gt 0 ]]; do
|
||
arg_lower="${1,,}"
|
||
case "$arg_lower" in
|
||
install|remove|clean|uninstall)
|
||
ACTION="$arg_lower"
|
||
if [ "${ACTION}" = "uninstall" ] || [ "${ACTION}" = "clean" ]; then
|
||
ACTION="remove"
|
||
fi
|
||
shift
|
||
;;
|
||
-f|--profile|--env-file)
|
||
PROFILE_ARG="$2"
|
||
shift 2
|
||
;;
|
||
--all-profiles)
|
||
PROFILE_ARG="all"
|
||
shift
|
||
;;
|
||
-e|--env)
|
||
ENV_MODE="${2,,}"
|
||
CLI_ENV_SET=1
|
||
shift 2
|
||
;;
|
||
-b|--backend)
|
||
BACKEND_MODE="${2,,}"
|
||
CLI_BACKEND_SET=1
|
||
shift 2
|
||
;;
|
||
-r|--role)
|
||
ROLE_TARGET="${2,,}"
|
||
CLI_ROLE_SET=1
|
||
shift 2
|
||
;;
|
||
--skip-assets)
|
||
SYNC_ASSETS="skip"
|
||
CLI_ASSETS_SET=1
|
||
shift
|
||
;;
|
||
--force-assets)
|
||
SYNC_ASSETS="force"
|
||
CLI_ASSETS_SET=1
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
show_help
|
||
;;
|
||
*)
|
||
if [ "$arg_lower" = "server" ] || [ "$arg_lower" = "node" ] || [ "$arg_lower" = "all" ]; then
|
||
ROLE_TARGET="$arg_lower"
|
||
CLI_ROLE_SET=1
|
||
elif [ "$arg_lower" = "local" ] || [ "$arg_lower" = "remote" ]; then
|
||
ENV_MODE="$arg_lower"
|
||
CLI_ENV_SET=1
|
||
elif [ "$arg_lower" = "compose" ] || [ "$arg_lower" = "systemd" ]; then
|
||
BACKEND_MODE="$arg_lower"
|
||
CLI_BACKEND_SET=1
|
||
else
|
||
echo -e "${RED}错误: 未知参数 '$1'。使用 '$0 --help' 查看用法。${NC}"
|
||
exit 1
|
||
fi
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# 命令行指定多 Profile 或目录自动展开解析
|
||
if [ -n "${PROFILE_ARG}" ]; then
|
||
CLI_PROFILES=()
|
||
if [ "${PROFILE_ARG}" = "all" ] || [ -d "${PROFILE_ARG}" ]; then
|
||
pdir="${PROFILE_ARG}"
|
||
[ "${pdir}" = "all" ] && pdir="deploy.env.d"
|
||
for p in "${pdir}"/*.env; do
|
||
if [ -f "$p" ] && [[ "$p" != *.example ]]; then
|
||
CLI_PROFILES+=("$p")
|
||
fi
|
||
done
|
||
else
|
||
IFS=',' read -ra ADDR <<< "${PROFILE_ARG}"
|
||
for p in "${ADDR[@]}"; do
|
||
if [ -f "$p" ]; then
|
||
CLI_PROFILES+=("$p")
|
||
elif [ -f "deploy.env.d/${p}" ]; then
|
||
CLI_PROFILES+=("deploy.env.d/${p}")
|
||
elif [ -f "deploy.env.d/${p}.env" ]; then
|
||
CLI_PROFILES+=("deploy.env.d/${p}.env")
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [ ${#CLI_PROFILES[@]} -gt 1 ]; then
|
||
batch_deploy_profiles "${CLI_PROFILES[@]}"
|
||
elif [ ${#CLI_PROFILES[@]} -eq 1 ]; then
|
||
load_profile_file "${CLI_PROFILES[0]}"
|
||
fi
|
||
fi
|
||
|
||
# =============================================================================
|
||
# 2. 交互式向导 (参数未指定时调用)
|
||
# =============================================================================
|
||
if [ -z "${ENV_MODE}" ] || [ -z "${BACKEND_MODE}" ] || [ -z "${ROLE_TARGET}" ]; then
|
||
echo -e "${BLUE}==============================================================${NC}"
|
||
echo -e "${CYAN} 🚀 DCTS 部署与管理控制台 ${NC}"
|
||
echo -e "${BLUE}==============================================================${NC}"
|
||
|
||
# [零步]:检测 deploy.env.d 中的 Profile 预设
|
||
if [ -z "${PROFILE_LOADED}" ] && [ -z "${PROFILE_ARG}" ] && [ -d "deploy.env.d" ]; then
|
||
valid_profiles=()
|
||
for p in deploy.env.d/*.env; do
|
||
if [ -f "$p" ] && [[ "$p" != *.example ]]; then
|
||
valid_profiles+=("$p")
|
||
fi
|
||
done
|
||
|
||
if [ ${#valid_profiles[@]} -gt 0 ]; then
|
||
echo -e "\n${YELLOW}【预设 Profile】检测到可用的服务器部署 Profile 配置文件:${NC}"
|
||
echo " 0) 手动交互式配置新服务器 (不加载预设 Profile)"
|
||
idx=1
|
||
for p in "${valid_profiles[@]}"; do
|
||
pname=$(basename "$p" .env)
|
||
p_ip=$(grep -E '^REMOTE_IP=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||
p_role=$(grep -E '^DEPLOY_ROLE=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||
p_node=$(grep -E '^DCTS_NODE_ID=' "$p" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
|
||
echo " ${idx}) ${pname} (IP: ${p_ip:-未指定}, 角色: ${p_role:-全量}, NodeID: ${p_node:-默认})"
|
||
((idx++))
|
||
done
|
||
if [ ${#valid_profiles[@]} -gt 1 ]; then
|
||
echo " a) [批量部署] 依次一键部署上述全部 ${#valid_profiles[@]} 个 Profile 节点"
|
||
fi
|
||
read -p "请选择预设 Profile [序号如 1, 多个如 1,2 或 1-2, 全部如 a, 默认 0]: " PROFILE_CHOICE
|
||
PROFILE_CHOICE=${PROFILE_CHOICE:-0}
|
||
|
||
SELECTED_PROFILES=()
|
||
if [[ "${PROFILE_CHOICE,,}" == "a" ]] || [[ "${PROFILE_CHOICE,,}" == "all" ]]; then
|
||
SELECTED_PROFILES=("${valid_profiles[@]}")
|
||
elif [[ "${PROFILE_CHOICE}" =~ [0-9] ]]; then
|
||
IFS=',' read -ra ADDR <<< "${PROFILE_CHOICE}"
|
||
for part in "${ADDR[@]}"; do
|
||
if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
|
||
s="${BASH_REMATCH[1]}"
|
||
e="${BASH_REMATCH[2]}"
|
||
for ((i=s; i<=e; i++)); do
|
||
if [ "$i" -ge 1 ] && [ "$i" -le "${#valid_profiles[@]}" ]; then
|
||
SELECTED_PROFILES+=("${valid_profiles[$((i-1))]}")
|
||
fi
|
||
done
|
||
elif [[ "$part" =~ ^[0-9]+$ ]]; then
|
||
if [ "$part" -ge 1 ] && [ "$part" -le "${#valid_profiles[@]}" ]; then
|
||
SELECTED_PROFILES+=("${valid_profiles[$((part-1))]}")
|
||
fi
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [ ${#SELECTED_PROFILES[@]} -gt 1 ]; then
|
||
batch_deploy_profiles "${SELECTED_PROFILES[@]}"
|
||
elif [ ${#SELECTED_PROFILES[@]} -eq 1 ]; then
|
||
load_profile_file "${SELECTED_PROFILES[0]}"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# [第一步]:选择环境
|
||
if [ -z "${ENV_MODE}" ]; then
|
||
echo -e "\n${YELLOW}【第一步】请选择部署环境:${NC}"
|
||
echo " 1) 本地部署 [Local]"
|
||
echo " 2) 远程部署 [Remote] (目标: ${REMOTE_USER}@${REMOTE_IP})"
|
||
read -p "请选择 [1-2, 默认 1]: " ENV_CHOICE
|
||
ENV_CHOICE=${ENV_CHOICE:-1}
|
||
case "$ENV_CHOICE" in
|
||
1) ENV_MODE="local" ;;
|
||
2) ENV_MODE="remote" ;;
|
||
*) echo -e "${RED}输入无效,默认选择 local。${NC}"; ENV_MODE="local" ;;
|
||
esac
|
||
fi
|
||
|
||
# [第二步]:选择平台及动作
|
||
if [ -z "${BACKEND_MODE}" ]; then
|
||
echo -e "\n${YELLOW}【第二步】请选择运行平台及操作:${NC}"
|
||
echo " 1) Docker Compose 容器化部署 (推荐)"
|
||
echo " 2) Systemd 原生后台服务部署"
|
||
echo " 3) 停止并清理 Docker Compose 容器服务"
|
||
echo " 4) 停止并清理 Systemd 后台服务"
|
||
read -p "请选择 [1-4, 默认 1]: " BACKEND_CHOICE
|
||
BACKEND_CHOICE=${BACKEND_CHOICE:-1}
|
||
case "$BACKEND_CHOICE" in
|
||
1) BACKEND_MODE="compose"; [ "${ACTION}" != "remove" ] && ACTION="install" ;;
|
||
2) BACKEND_MODE="systemd"; [ "${ACTION}" != "remove" ] && ACTION="install" ;;
|
||
3) BACKEND_MODE="compose"; ACTION="remove" ;;
|
||
4) BACKEND_MODE="systemd"; ACTION="remove" ;;
|
||
*) echo -e "${RED}输入无效,默认选择 compose (安装)。${NC}"; BACKEND_MODE="compose"; [ "${ACTION}" != "remove" ] && ACTION="install" ;;
|
||
esac
|
||
fi
|
||
|
||
# [第三步]:选择目标模块
|
||
if [ -z "${ROLE_TARGET}" ]; then
|
||
echo -e "\n${YELLOW}【第三步】请选择目标模块:${NC}"
|
||
echo " 1) 全量服务 [All: server + node]"
|
||
echo " 2) 仅主控服务端 [Server]"
|
||
echo " 3) 仅计算节点 [Node]"
|
||
read -p "请选择 [1-3, 默认 1]: " ROLE_CHOICE
|
||
ROLE_CHOICE=${ROLE_CHOICE:-1}
|
||
case "$ROLE_CHOICE" in
|
||
1) ROLE_TARGET="all" ;;
|
||
2) ROLE_TARGET="server" ;;
|
||
3) ROLE_TARGET="node" ;;
|
||
*) echo -e "${RED}输入无效,默认选择 all。${NC}"; ROLE_TARGET="all" ;;
|
||
esac
|
||
fi
|
||
|
||
# [第四步]:远程部署安装时选择静态资源同步策略 (已加载 Profile 时自动跳过)
|
||
if [ -z "${PROFILE_LOADED}" ] && [ "${ENV_MODE}" = "remote" ] && [ "${ACTION}" != "remove" ]; then
|
||
default_asset_choice=1
|
||
default_asset_desc="1 (Auto)"
|
||
if [ "${ROLE_TARGET}" = "node" ]; then
|
||
default_asset_choice=2
|
||
default_asset_desc="2 (Skip, 仅节点推荐)"
|
||
fi
|
||
|
||
echo -e "\n${YELLOW}【第四步】请选择静态资源 (assets/) 同步策略:${NC}"
|
||
echo " 1) 自动模式 [Auto] (推荐: rsync 增量同步;若退化至 scp,目标端已有资源时自动跳过)"
|
||
echo " 2) 跳过同步 [Skip] (等同 --skip-assets,仅更新程序代码,不传输静态资源)"
|
||
echo " 3) 强制同步 [Force] (等同 --force-assets,无视目标端状态,强制全量覆盖传输)"
|
||
read -p "请选择 [1-3, 默认 ${default_asset_desc}]: " ASSET_CHOICE
|
||
ASSET_CHOICE=${ASSET_CHOICE:-$default_asset_choice}
|
||
case "$ASSET_CHOICE" in
|
||
1) SYNC_ASSETS="auto" ;;
|
||
2) SYNC_ASSETS="skip" ;;
|
||
3) SYNC_ASSETS="force" ;;
|
||
*) SYNC_ASSETS=$([ "$default_asset_choice" = "2" ] && echo "skip" || echo "auto") ;;
|
||
esac
|
||
fi
|
||
|
||
echo -e "${BLUE}==============================================================${NC}"
|
||
fi
|
||
|
||
# 参数校验
|
||
case "$ENV_MODE" in
|
||
local|remote) ;;
|
||
*) echo -e "${RED}错误: 不支持的环境类型 '$ENV_MODE'${NC}"; exit 1 ;;
|
||
esac
|
||
case "$BACKEND_MODE" in
|
||
compose|systemd) ;;
|
||
*) echo -e "${RED}错误: 不支持的平台类型 '$BACKEND_MODE'${NC}"; exit 1 ;;
|
||
esac
|
||
case "$ROLE_TARGET" in
|
||
all|server|node) ;;
|
||
*) echo -e "${RED}错误: 不支持的模块类型 '$ROLE_TARGET'${NC}"; exit 1 ;;
|
||
esac
|
||
|
||
# 变量映射
|
||
if [ "$ROLE_TARGET" = "all" ]; then
|
||
SERVICES="server node"
|
||
ROLES="server node"
|
||
ROLE_DESC="全量模块 (server + node)"
|
||
TAR_NAME="dcts_images_all.tar.gz"
|
||
elif [ "$ROLE_TARGET" = "server" ]; then
|
||
SERVICES="server"
|
||
ROLES="server"
|
||
ROLE_DESC="服务端 (server)"
|
||
TAR_NAME="dcts_images_server.tar.gz"
|
||
else
|
||
SERVICES="node"
|
||
ROLES="node"
|
||
ROLE_DESC="计算节点 (node)"
|
||
TAR_NAME="dcts_images_node.tar.gz"
|
||
fi
|
||
|
||
# 纯 CLI 未指定资源同步策略时,默认 auto(增量同步)
|
||
SYNC_ASSETS="${SYNC_ASSETS:-auto}"
|
||
|
||
echo -e "${CYAN}📢 配置确认: 环境=[${YELLOW}${ENV_MODE}${CYAN}] 平台=[${YELLOW}${BACKEND_MODE}${CYAN}] 模块=[${YELLOW}${ROLE_DESC}${CYAN}] 动作=[${YELLOW}${ACTION}${CYAN}] 资源同步=[${YELLOW}${SYNC_ASSETS}${CYAN}]${NC}\n"
|
||
|
||
# =============================================================================
|
||
# 静态资源同步函数
|
||
# =============================================================================
|
||
check_remote_is_windows() {
|
||
local kernel
|
||
kernel=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "uname -s 2>/dev/null || cmd /c ver 2>/dev/null" 2>/dev/null || echo "Windows")
|
||
if [[ "${kernel}" == *"Linux"* ]] || [[ "${kernel}" == *"GNU"* ]]; then
|
||
echo "false"
|
||
else
|
||
echo "true"
|
||
fi
|
||
}
|
||
|
||
sync_remote_assets() {
|
||
if [ "${SYNC_ASSETS}" = "skip" ]; then
|
||
echo -e "${YELLOW}[i] 已启用 --skip-assets,跳过静态资源 (assets/) 同步。${NC}"
|
||
return 0
|
||
fi
|
||
|
||
if [ "${ROLE_TARGET}" = "node" ] && [ "${SYNC_ASSETS}" != "force" ]; then
|
||
echo -e "${YELLOW}[i] 目标模块为仅节点 (node),默认跳过静态资源 (assets/) 同步。${NC}"
|
||
return 0
|
||
fi
|
||
|
||
if [ ! -d "assets" ]; then
|
||
return 0
|
||
fi
|
||
|
||
echo "=== 检查并同步静态资源 (assets/) ==="
|
||
local is_win
|
||
is_win=$(check_remote_is_windows)
|
||
|
||
if [ "$is_win" = "true" ]; then
|
||
local remote_has_assets
|
||
remote_has_assets=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Test-Path '${REMOTE_DIR}/assets'\" 2>NUL || cmd /c \"if exist \\\"${REMOTE_DIR}\\assets\\\" echo True\" 2>NUL" 2>/dev/null | grep -i "True" || echo "")
|
||
if [ -n "${remote_has_assets}" ] && [ "${SYNC_ASSETS}" != "force" ]; then
|
||
echo -e "${GREEN}[√] 远端 Windows 已有 assets/ 目录。为避免 scp 全量传输数据,本次自动跳过 assets/ 同步。${NC}"
|
||
else
|
||
echo -e "${CYAN} -> 开始向远端 Windows 传输 assets/ 静态资源数据...${NC}"
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r assets "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
local local_rsync=false
|
||
local remote_rsync=false
|
||
if command -v rsync >/dev/null 2>&1; then local_rsync=true; fi
|
||
if ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "command -v rsync" >/dev/null 2>&1; then remote_rsync=true; fi
|
||
|
||
if [ "$local_rsync" = true ] && [ "$remote_rsync" = true ]; then
|
||
echo " -> 双方已就绪 rsync,开始增量同步 assets/..."
|
||
rsync -avz -e "ssh -p ${REMOTE_PORT} ${SSH_OPTS}" assets/ "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/assets/"
|
||
else
|
||
echo -e "${YELLOW}[!] 未检测到完备的 rsync,回退至 scp 模式...${NC}"
|
||
local remote_has_assets
|
||
remote_has_assets=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "[ -d '${REMOTE_DIR}/assets' ] && ls -A '${REMOTE_DIR}/assets' 2>/dev/null | head -n 1 || echo ''")
|
||
|
||
if [ -n "${remote_has_assets}" ] && [ "${SYNC_ASSETS}" != "force" ]; then
|
||
echo -e "${GREEN}[√] 远端已有 assets/ 目录。为避免 scp 全量传输 2GB+ 数据,本次自动跳过 assets/ 同步。${NC}"
|
||
echo -e " (如需强制覆盖更新静态资源,请附加 --force-assets 参数或在交互向导中选择 Force 模式)"
|
||
else
|
||
if [ -n "${remote_has_assets}" ]; then
|
||
echo -e "${YELLOW}[!] 已指定 --force-assets,开始通过 scp 强制传输全量 assets/ 数据...${NC}"
|
||
else
|
||
echo -e "${CYAN} -> 远端为新部署环境,开始传输初始 assets/ 数据...${NC}"
|
||
fi
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r assets "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
sync_remote_workflows() {
|
||
if [ -d "workflows" ]; then
|
||
echo -e "${CYAN} -> 同步工作流配置文件 workflows/ 到远端...${NC}"
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} -r workflows "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
|
||
fi
|
||
}
|
||
|
||
setup_ssh_control() {
|
||
# ControlPath 以 user@ip:port 为 key (不含 PID),使同一目标主机的父子/兄弟进程
|
||
# 共享同一条复用 socket;批量部署递归调用子进程时无需重复输入密码。
|
||
local socket_key="${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}"
|
||
SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/${socket_key}"
|
||
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_PATH} -o ControlPersist=1800"
|
||
# 非直连节点:profile 提供 REMOTE_PROXY_JUMP 时经跳板机转发(对齐 fetch_results.sh)
|
||
[ -n "${REMOTE_PROXY_JUMP:-}" ] && SSH_OPTS="${SSH_OPTS} -o ProxyJump=${REMOTE_PROXY_JUMP}"
|
||
mkdir -p "${SSH_CONTROL_DIR}"
|
||
|
||
# 若已有活跃的主连接 (如批量部署父进程已建立),直接复用,避免重复认证。
|
||
# 此时不注册 cleanup (连接由建立者管理),避免子进程退出时关闭共享连接影响后续节点。
|
||
if ssh -p "${REMOTE_PORT}" -o ControlPath="${SSH_CONTROL_PATH}" -O check "${REMOTE_USER}@${REMOTE_IP}" >/dev/null 2>&1; then
|
||
SSH_REUSED_CONNECTION=true
|
||
return 0
|
||
fi
|
||
|
||
echo "=== 1. 建立 SSH 主连接复用 (SSH ControlMaster) ==="
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} -fN "${REMOTE_USER}@${REMOTE_IP}" || true
|
||
# 仅当本进程建立了主连接时,才在退出时清理;复用的连接交给建立者管理。
|
||
SSH_REUSED_CONNECTION=false
|
||
cleanup_cm() { [ "${SSH_REUSED_CONNECTION}" = "true" ] && return 0; ssh -p "${REMOTE_PORT}" -o ControlPath="${SSH_CONTROL_PATH}" "${REMOTE_USER}@${REMOTE_IP}" -O exit 2>/dev/null || true; }
|
||
trap cleanup_cm EXIT
|
||
}
|
||
|
||
# =============================================================================
|
||
# 3. 清理/卸载流程 (ACTION="remove")
|
||
# =============================================================================
|
||
if [ "${ACTION}" = "remove" ]; then
|
||
if [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "compose" ]; then
|
||
echo -e "${YELLOW}停止并清理本地 Docker Compose 服务 [${SERVICES}]...${NC}"
|
||
if [ "${ROLE_TARGET}" = "all" ]; then
|
||
docker compose down 2>/dev/null || true
|
||
else
|
||
docker compose stop ${SERVICES} 2>/dev/null || true
|
||
docker compose rm -f -s -v ${SERVICES} 2>/dev/null || true
|
||
fi
|
||
echo -e "${GREEN}[✓] 本地 Docker Compose 服务已清理。${NC}"
|
||
|
||
elif [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
|
||
if [ "$EUID" -ne 0 ]; then
|
||
echo -e "${YELLOW}[!] 卸载 Systemd 服务需要 root 权限,正在申请提权...${NC}"
|
||
exec sudo -E "$0" -e local -b systemd -r "${ROLE_TARGET}" remove
|
||
fi
|
||
for r in ${ROLES}; do
|
||
SVC="dcts-${r}.service"
|
||
echo -e "${YELLOW}卸载 Systemd 服务 [${SVC}]...${NC}"
|
||
if systemctl is-active --quiet "dcts-${r}" 2>/dev/null; then systemctl stop "dcts-${r}" || true; fi
|
||
if systemctl is-enabled --quiet "dcts-${r}" 2>/dev/null; then systemctl disable "dcts-${r}" || true; fi
|
||
rm -f "/etc/systemd/system/${SVC}"
|
||
echo -e "${GREEN}[✓] 服务 ${SVC} 已彻底卸载。${NC}"
|
||
done
|
||
systemctl daemon-reload || true
|
||
systemctl reset-failed 2>/dev/null || true
|
||
|
||
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "compose" ]; then
|
||
echo -e "${YELLOW}清理远端 Docker Compose 服务 [${REMOTE_USER}@${REMOTE_IP}]...${NC}"
|
||
setup_ssh_control
|
||
is_win=$(check_remote_is_windows)
|
||
if [ "${ROLE_TARGET}" = "all" ]; then
|
||
if [ "$is_win" = "true" ]; then
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; docker compose down\"" 2>/dev/null || true
|
||
else
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && docker compose down" 2>/dev/null || true
|
||
fi
|
||
else
|
||
if [ "$is_win" = "true" ]; then
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; docker compose stop ${SERVICES}; docker compose rm -f -s -v ${SERVICES}\"" 2>/dev/null || true
|
||
else
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && docker compose stop ${SERVICES} && docker compose rm -f -s -v ${SERVICES}" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
echo -e "${GREEN}[✓] 远端 Docker Compose 服务已清理。${NC}"
|
||
|
||
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
|
||
echo -e "${YELLOW}清理远端 Systemd 服务 [${REMOTE_USER}@${REMOTE_IP}]...${NC}"
|
||
setup_ssh_control
|
||
is_win=$(check_remote_is_windows)
|
||
if [ "$is_win" = "true" ]; then
|
||
echo -e "${RED}错误: Systemd 不支持 Windows 远端目标,无法清理。${NC}"
|
||
exit 1
|
||
fi
|
||
ssh -t -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && sudo ./scripts/deploy.sh -e local -b systemd -r ${ROLE_TARGET} remove"
|
||
echo -e "${GREEN}[✓] 远端 Systemd 服务已清理。${NC}"
|
||
fi
|
||
exit 0
|
||
fi
|
||
|
||
# =============================================================================
|
||
# 4. 安装/更新流程 (ACTION="install")
|
||
# =============================================================================
|
||
|
||
# ─── 4.1 本地 + Docker Compose ────────────────────────────────────────────────
|
||
if [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "compose" ]; then
|
||
echo -e "${CYAN}>>> 预先创建挂载目录并配置读取与写入权限 (UID 65532)...${NC}"
|
||
mkdir -p ./data ./data/logs ./data/runtime ./data/work ./data/seeds ./data/result ./assets ./workflows
|
||
# 注:不再 chmod -R 777 ./data —— 该目录含 dcts.db / 队列库 / 备份 / .node_token 明文,
|
||
# 777 会让同机任意用户可读凭据并写删数据库。容器内由 UID 65532 运行,下方 chown 即满足权限。
|
||
if command -v docker >/dev/null 2>&1; then
|
||
docker run --rm -v "$(pwd)":/work alpine chown -R 65532:65532 /work/data 2>/dev/null || true
|
||
fi
|
||
|
||
echo -e "${CYAN}>>> 启动本地 Docker Compose 服务构建与运行...${NC}"
|
||
docker compose build ${SERVICES}
|
||
if [ "${ROLE_TARGET}" = "all" ]; then
|
||
docker compose up -d --force-recreate
|
||
else
|
||
docker compose up -d --force-recreate ${SERVICES}
|
||
fi
|
||
echo -e " -> 等待 3 秒进行运行状态检测..."
|
||
sleep 3 && docker compose ps
|
||
echo -e "${GREEN}🎉 本地 Docker Compose 服务已成功启动!${NC}"
|
||
if [[ "${ROLES}" == *"server"* ]]; then
|
||
PORT=${DCTS_PORT:-8090}
|
||
echo -e "👉 面板访问地址: ${CYAN}http://localhost:${PORT}${NC}"
|
||
fi
|
||
|
||
# ─── 4.2 远程 + Docker Compose ────────────────────────────────────────────────
|
||
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "compose" ]; then
|
||
setup_ssh_control
|
||
|
||
if [ -n "${PREBUILT_TAR}" ]; then
|
||
# 批量模式: PREBUILT_TAR 为父进程按角色分包的镜像归档 (空格分隔,可能多个)。
|
||
# 仅当所有包均存在时才复用;否则回退到本进程自行构建打包。
|
||
all_prebuilt_exist=true
|
||
PREBUILT_LIST=()
|
||
for t in ${PREBUILT_TAR}; do
|
||
if [ -f "${t}" ]; then
|
||
PREBUILT_LIST+=("${t}")
|
||
else
|
||
all_prebuilt_exist=false
|
||
fi
|
||
done
|
||
if [ "${all_prebuilt_exist}" = true ] && [ ${#PREBUILT_LIST[@]} -gt 0 ]; then
|
||
echo "=== 2 & 3. 复用全局构建阶段一生成的镜像归档包 [${PREBUILT_LIST[*]}] ==="
|
||
TAR_LIST=("${PREBUILT_LIST[@]}")
|
||
else
|
||
echo -e "${YELLOW}[!] 部分预构建镜像包缺失,回退至本进程自行构建打包...${NC}"
|
||
PREBUILT_TAR=""
|
||
fi
|
||
fi
|
||
|
||
if [ -z "${PREBUILT_TAR}" ]; then
|
||
echo "=== 2. 在本地构建 Docker 镜像 [${SERVICES}] ==="
|
||
docker compose build ${SERVICES}
|
||
|
||
echo "=== 3. 导出镜像至归档包: ${TAR_NAME} ==="
|
||
if [ "$ROLE_TARGET" = "all" ]; then
|
||
docker save dcts-server:latest dcts-node:latest | gzip > "${TAR_NAME}"
|
||
elif [ "$ROLE_TARGET" = "server" ]; then
|
||
docker save dcts-server:latest | gzip > "${TAR_NAME}"
|
||
else
|
||
docker save dcts-node:latest | gzip > "${TAR_NAME}"
|
||
fi
|
||
TAR_LIST=("${TAR_NAME}")
|
||
fi
|
||
|
||
echo "=== 4. 同步静态资源与配置文件 ==="
|
||
is_win=$(check_remote_is_windows)
|
||
|
||
if [ "$is_win" = "true" ]; then
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"New-Item -ItemType Directory -Force -Path '${REMOTE_DIR}'\" 2>NUL || cmd /c \"if not exist \\\"${REMOTE_DIR}\\\" mkdir \\\"${REMOTE_DIR}\\\"\" 2>NUL" >/dev/null 2>&1 || true
|
||
else
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "mkdir -p '${REMOTE_DIR}'"
|
||
fi
|
||
|
||
sync_remote_assets
|
||
sync_remote_workflows
|
||
|
||
write_remote_runtime_env "$is_win"
|
||
|
||
# 传输所有镜像归档包 (scp 落地名为 basename,丢掉本地 data/ 前缀)。
|
||
# 同时收集远端引用名 (纯文件名,与 docker load 的路径对齐)。
|
||
REMOTE_TAR_NAMES=()
|
||
for t in "${TAR_LIST[@]}"; do
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} "${t}" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
|
||
REMOTE_TAR_NAMES+=("$(basename "${t}")")
|
||
done
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} docker-compose.yml "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/"
|
||
|
||
echo "=== 5. 远端预设目录权限、导入镜像并启动 Docker Compose 服务 ==="
|
||
# 构造远端批量导入与清理命令 (逐个包 docker load 后删除)
|
||
remote_load_cmd=""
|
||
for rn in "${REMOTE_TAR_NAMES[@]}"; do
|
||
remote_load_cmd="${remote_load_cmd}docker load -i '${rn}' && rm -f '${rn}' && "
|
||
done
|
||
remote_load_cmd="${remote_load_cmd}true"
|
||
|
||
if [ "$is_win" = "true" ]; then
|
||
win_load_cmd=""
|
||
for rn in "${REMOTE_TAR_NAMES[@]}"; do
|
||
win_load_cmd="${win_load_cmd}docker load -i '${rn}'; Remove-Item -Force '${rn}' -ErrorAction SilentlyContinue; "
|
||
done
|
||
win_load_cmd="${win_load_cmd}Write-Output done"
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; New-Item -ItemType Directory -Force -Path 'data/logs','data/seeds','data/result','data/node_work','assets','workflows' -ErrorAction SilentlyContinue; ${win_load_cmd}; docker compose up -d --force-recreate ${SERVICES}; Start-Sleep -Seconds 3; docker compose ps\""
|
||
else
|
||
# 远端属主修正:容器内 UID 65532(dcts) 需对 ./data 有写权限才能归档产物。
|
||
# 优先 docker run alpine chown(不依赖远端 root);失败时若远端本身是 root 登录,
|
||
# 退化用系统 chown。任一成功即可,全失败则明显告警——旧版 `|| true` 会静默吞错,
|
||
# 导致节点上线后归档一律 Permission denied、默默丢失科学产物。
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && mkdir -p ./data ./data/logs ./data/seeds ./data/result ./data/node_work ./assets ./workflows 2>/dev/null || true && ( { docker run --rm -v \"\$(pwd)\":/work alpine chown -R 65532:65532 /work/data && echo CHOWN_OK; } || { [ \"\$(id -u)\" = 0 ] && chown -R 65532:65532 ./data && echo CHOWN_OK_SYS; } || { echo '!! chown 失败:远端既无 docker 也非 root,data 属主未修正,节点归档将 Permission denied' >&2; exit 1; } ) && ${remote_load_cmd} && docker compose up -d --force-recreate ${SERVICES} && sleep 3 && docker compose ps"
|
||
fi
|
||
|
||
if [ -z "${PREBUILT_TAR}" ]; then
|
||
echo "=== 6. 清理本地临时归档包 ==="
|
||
for t in "${TAR_LIST[@]}"; do
|
||
rm -f "${t}"
|
||
done
|
||
fi
|
||
echo -e "${GREEN}🎉 远程 Docker Compose [${ROLE_DESC}] 部署完成!${NC}"
|
||
if [[ "${ROLES}" == *"server"* ]]; then
|
||
echo -e "👉 远程面板访问地址: ${CYAN}http://${REMOTE_IP}:8090${NC}"
|
||
fi
|
||
|
||
# ─── 4.3 本地 + Systemd ───────────────────────────────────────────────────────
|
||
elif [ "${ENV_MODE}" = "local" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
|
||
if [ "$EUID" -ne 0 ]; then
|
||
echo -e "${YELLOW}[!] 安装 Systemd 服务需要 root 权限,正在申请提权...${NC}"
|
||
exec sudo -E "$0" -e local -b systemd -r "${ROLE_TARGET}" install
|
||
fi
|
||
|
||
TARGET_USER="${DCTS_DAEMON_USER:-${SUDO_USER:-$USER}}"
|
||
[ "$TARGET_USER" = "root" ] && [ "$(id -u fmq 2>/dev/null || echo 0)" -ne 0 ] && TARGET_USER="fmq"
|
||
REAL_USER="${TARGET_USER}"
|
||
|
||
if [ ! -f "${WORK_DIR}/.env" ]; then
|
||
if [ -f "${WORK_DIR}/.env.example" ]; then
|
||
cp "${WORK_DIR}/.env.example" "${WORK_DIR}/.env"
|
||
chown "${REAL_USER}:${REAL_USER}" "${WORK_DIR}/.env" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
find_binary() {
|
||
local n="$1"
|
||
if [ -f "${WORK_DIR}/target/release/${n}" ]; then echo "${WORK_DIR}/target/release/${n}"
|
||
elif [ -f "${WORK_DIR}/${n}" ]; then echo "${WORK_DIR}/${n}"
|
||
elif [ -f "${WORK_DIR}/target/debug/${n}" ]; then echo "${WORK_DIR}/target/debug/${n}"
|
||
else echo ""; fi
|
||
}
|
||
|
||
for r in ${ROLES}; do
|
||
BIN_PATH=$(find_binary "$r")
|
||
if [ -z "$BIN_PATH" ]; then
|
||
echo -e "${YELLOW}[!] 未找到预编译的 ${r} 二进制文件,开始自动编译...${NC}"
|
||
if command -v cargo >/dev/null 2>&1; then
|
||
sudo -u "${REAL_USER}" env "PATH=$PATH:$HOME/.cargo/bin" cargo build --release -p "$r"
|
||
BIN_PATH="${WORK_DIR}/target/release/${r}"
|
||
else
|
||
echo -e "${RED}错误: 未找到可执行文件且无 Cargo 编译环境,部署终止。${NC}"
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
SVC_NAME="dcts-${r}"
|
||
SVC_FILE="/etc/systemd/system/${SVC_NAME}.service"
|
||
SVC_DESC="DCTS ${r^} Daemon Service"
|
||
echo -e "${CYAN}=== 配置 Systemd 服务 [${SVC_NAME}] ===${NC}"
|
||
|
||
cat <<EOF > "${SVC_FILE}"
|
||
[Unit]
|
||
Description=${SVC_DESC}
|
||
After=network.target network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=${REAL_USER}
|
||
WorkingDirectory=${WORK_DIR}
|
||
ExecStart=${BIN_PATH}
|
||
Restart=always
|
||
RestartSec=5s
|
||
Environment=RUST_LOG=info
|
||
EnvironmentFile=-${WORK_DIR}/.env
|
||
LimitNOFILE=65536
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
chmod 644 "${SVC_FILE}"
|
||
echo -e "${GREEN}[✓] 服务配置文件已写入: ${SVC_FILE}${NC}"
|
||
systemctl daemon-reload
|
||
systemctl enable "${SVC_NAME}"
|
||
systemctl restart "${SVC_NAME}"
|
||
echo -e "${GREEN}[✓] 服务 ${SVC_NAME} 已启动并配置开机自启。${NC}"
|
||
done
|
||
|
||
LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||
LOCAL_IP=${LOCAL_IP:-"127.0.0.1"}
|
||
echo -e "\n${GREEN}🎊 本地 Systemd [${ROLE_DESC}] 服务部署完成!${NC}"
|
||
if [[ "${ROLES}" == *"server"* ]]; then
|
||
PORT=${DCTS_PORT:-8090}
|
||
echo -e "📌 面板访问地址: ${CYAN}http://localhost:${PORT}${NC} 或 ${CYAN}http://${LOCAL_IP}:${PORT}${NC}"
|
||
fi
|
||
echo -e "🛠 运维指令: ${YELLOW}sudo systemctl [status|restart|stop] dcts-<server|node>${NC}"
|
||
|
||
# ─── 4.4 远程 + Systemd ───────────────────────────────────────────────────────
|
||
elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "systemd" ]; then
|
||
setup_ssh_control
|
||
|
||
is_win=$(check_remote_is_windows)
|
||
if [ "$is_win" = "true" ]; then
|
||
echo -e "${RED}错误: Systemd 服务不支持 Windows 远端目标,请改用 Docker Compose 部署 (-b compose)。${NC}"
|
||
exit 1
|
||
fi
|
||
|
||
echo "=== 2. 检查架构与准备二进制文件 [${ROLES}] ==="
|
||
REMOTE_ARCH=$(ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "uname -m 2>/dev/null || echo unknown")
|
||
LOCAL_ARCH=$(uname -m)
|
||
if [ "${REMOTE_ARCH}" != "unknown" ] && [ "${REMOTE_ARCH}" != "${LOCAL_ARCH}" ]; then
|
||
echo -e "${YELLOW}[!] 警告: 本地架构 (${LOCAL_ARCH}) 与远程架构 (${REMOTE_ARCH}) 不一致,请确认已针对目标架构完成交叉编译。${NC}"
|
||
fi
|
||
|
||
for r in ${ROLES}; do
|
||
if [ ! -f "target/release/${r}" ] && [ ! -f "${r}" ]; then
|
||
echo -e "${YELLOW}未找到 ${r} release 产物,开始本地编译...${NC}"
|
||
cargo build --release -p "${r}"
|
||
fi
|
||
done
|
||
|
||
echo "=== 3. 传输文件与静态资源 ==="
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "mkdir -p '${REMOTE_DIR}/scripts' '${REMOTE_DIR}/data' '${REMOTE_DIR}/logs'"
|
||
sync_remote_assets
|
||
sync_remote_workflows
|
||
|
||
for r in ${ROLES}; do
|
||
BIN_SRC="target/release/${r}"
|
||
[ ! -f "${BIN_SRC}" ] && [ -f "${r}" ] && BIN_SRC="${r}"
|
||
echo " -> 传输二进制可执行文件: ${BIN_SRC}"
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} "${BIN_SRC}" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/${r}.tmp"
|
||
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "mv -f '${REMOTE_DIR}/${r}.tmp' '${REMOTE_DIR}/${r}' && chmod +x '${REMOTE_DIR}/${r}'"
|
||
done
|
||
|
||
write_remote_runtime_env "$is_win"
|
||
scp -P "${REMOTE_PORT}" ${SSH_OPTS} scripts/deploy.sh "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/scripts/"
|
||
|
||
echo "=== 4. 在远端配置并启动 Systemd 服务 ==="
|
||
ssh -t -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && chmod +x scripts/deploy.sh && sudo ./scripts/deploy.sh -e local -b systemd -r ${ROLE_TARGET} install"
|
||
|
||
echo -e "\n${GREEN}👑 远程 Systemd [${ROLE_DESC}] 服务部署完成!${NC}"
|
||
if [[ "${ROLES}" == *"server"* ]]; then
|
||
echo -e "👉 远程面板访问地址: ${CYAN}http://${REMOTE_IP}:8090${NC}"
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
exit 0
|