Files
DCTS/scripts/deploy.sh
T
fmq d16b3d3cdc feat(all): 数据库模块化拆分与版本化迁移、任务引擎命名体系收敛、物理输出校验加固与用户配置接通
- server/db: 拆 4929 行 db.rs 单体为 db/ 目录,migrations.rs 引入 PRAGMA user_version
    版本化迁移运行器(M1~M13)
  - 任务引擎 Phase 6/7b/7c 改名收敛:EngineStageConfig→PhaseConfig、StagePolicy→ResumePolicy、
    Converged→Completed、删除 task_type 列、success_method 拆 tlusty_/synspec_ 双列、
    新增 tlusty_status/synspec_status 半失败阶段守卫
  - 科学正确性加固:conv_check 任意行 NaN/Inf/溢出判无效(0 行容忍)、新增 spec_is_valid
    校验 SYNSPEC 脏谱、itek_history 逐次迭代全量保真、fmt_abn powf 溢出饱和
  - 用户配置真正接通:tlusty_chain/tlusty_input 由死字段经 调度器→TaskSpec→executor→runner
    透传生效;config 加载期 validate + deny_unknown_fields + 解析失败记 warn
  - 调度修复:H1 活锁(pending_strategies 跳过已失败策略)、种子查找错误不再静默降级冷启动
  - dashboard: 阶段配置面板 tlusty_stage/synspec_stage、"已完成"标签、迭代诊断展示
  - docs: 新增 database_refactor_design.md,同步 database/api/PIPELINE/workflow_detail
2026-08-06 20:51:21 +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} (仅打包 1 次)..."
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} (仅打包 1 次)..."
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