Files
DCTS/scripts/deploy.sh
T
fmq 43b82b1ae2 feat(all): 物理正确性五重硬门槛、输入文件结构化与 fort.55 错位修复、conv 诊断 DB 化与阶段归因修复、ORELAX 收敛修复与导入工具下线
物理正确性校验体系(common/conv_check.rs +494 行)
- 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac)
- runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false
- GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传

输入文件配置结构化重构(config.rs +1453 行)
- TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体
- 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖)
- fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段)

conv 诊断 DB 化与阶段归因修复(server)
- 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER)
- record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计
- 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段

收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node)
- nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散
- seed_chain 块可配,executor 优先采用用户配置而非内置默认链

导入工具下线
- 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点
- 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删)

文档与分析
- 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档
- spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
2026-08-09 12:09:48 +08:00

1040 lines
45 KiB
Bash
Executable File
Raw 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 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}"
if ssh -p "${r_port}" -o ControlPath="${sock}" -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 -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"
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