物理修复(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 篇根因分析/验证文档
455 lines
20 KiB
Bash
Executable File
455 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ==============================================================================
|
||
# fetch_results.sh — 从全部计算节点同步 data/result 完整科学产物到本地备份
|
||
# ==============================================================================
|
||
# 背景:node 端 result 目录永久保留全部网格点的完整产物(.spec/.cont/.iden/
|
||
# 各阶段快照/conv.json/日志等,撤销了早期的 MAX_RESULT_MODELS=200 LRU 上限)。
|
||
# 为做异地备份、以及便于失败归因与收敛验证,把各节点 result 目录整树拉回本地,
|
||
# 按节点分目录存放。
|
||
#
|
||
# 用法:
|
||
# ./scripts/fetch_results.sh # 无参数 → 交互式向导选择节点
|
||
# ./scripts/fetch_results.sh -i # 显式进入交互式向导
|
||
# ./scripts/fetch_results.sh -n node-fmq-dckj-02 # 非交互:仅同步指定节点(DCTS_NODE_ID)
|
||
# ./scripts/fetch_results.sh -d /backup/dcts-salvage # 覆盖备份根目录
|
||
# ./scripts/fetch_results.sh --with-work # 连带拉取 data/work 沙盒残留
|
||
# ./scripts/fetch_results.sh --include-local # 把本机节点也复制进备份树
|
||
# ./scripts/fetch_results.sh --force # 强制重拉:忽略本地已存在的同名目录,全部重新 scp 覆盖
|
||
#
|
||
# 传输策略(两类节点都走增量,重复运行只补差异、不重拷全量):
|
||
# - 双方均可用 rsync → rsync 增量(断点续传、幂等,自动补新增/变更文件)
|
||
# - 否则(典型 Windows:无 rsync)→ scp 目录级增量:先列举远端顶层网格点子目录,
|
||
# 只 scp 本地缺失的目录,已存在的不重拷(避免 win-01 的 52GB 全量重拷)
|
||
# 远端 rsync/列目录探测均用多 shell 兼容命令(POSIX/command-v/where/Get-Command、
|
||
# ls/dir/Get-ChildItem),适配 Linux + Windows(cmd/PowerShell 默认 shell)。
|
||
# SSH 复用 deploy.sh 的 ControlMaster,多次执行不重复输密码。
|
||
# ==============================================================================
|
||
|
||
set -euo pipefail
|
||
|
||
GREEN='\033[0;32m'
|
||
BLUE='\033[0;34m'
|
||
RED='\033[0;31m'
|
||
YELLOW='\033[1;33m'
|
||
CYAN='\033[0;36m'
|
||
NC='\033[0m'
|
||
|
||
# 定位至项目根目录(与 deploy.sh 一致)
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
WORK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||
cd "${WORK_DIR}"
|
||
|
||
# =============================================================================
|
||
# 默认配置(可被命令行参数覆盖)
|
||
# =============================================================================
|
||
SSH_CONTROL_DIR="${SSH_CONTROL_DIR:-$HOME/.ssh/cm}"
|
||
BACKUP_ROOT="${BACKUP_ROOT:-${WORK_DIR}/data/salvage}"
|
||
ONLY_NODE=""
|
||
WITH_WORK=false
|
||
INCLUDE_LOCAL=false
|
||
INTERACTIVE=false
|
||
FORCE_REPULL=false
|
||
HAD_ARGS=false
|
||
SELECTED_PROFILES=()
|
||
|
||
# =============================================================================
|
||
# 解析命令行参数
|
||
# =============================================================================
|
||
while [[ $# -gt 0 ]]; do
|
||
HAD_ARGS=true
|
||
case "$1" in
|
||
-n|--node) ONLY_NODE="$2"; shift 2 ;;
|
||
-d|--dest) BACKUP_ROOT="$2"; shift 2 ;;
|
||
-i|--interactive) INTERACTIVE=true; shift ;;
|
||
--with-work) WITH_WORK=true; shift ;;
|
||
--include-local) INCLUDE_LOCAL=true; shift ;;
|
||
--force|-f) FORCE_REPULL=true; shift ;;
|
||
-h|--help)
|
||
sed -n '2,32p' "$0"
|
||
exit 0 ;;
|
||
*) echo -e "${RED}未知参数: $1${NC}"; exit 1 ;;
|
||
esac
|
||
done
|
||
|
||
mkdir -p "${BACKUP_ROOT}"
|
||
|
||
# =============================================================================
|
||
# 工具函数
|
||
# =============================================================================
|
||
# 从 profile 文件读取键值(避免 source 造成变量污染;写法对齐 deploy.sh)
|
||
read_profile_var() { # $1=profile $2=键 $3=默认值
|
||
local p="$1" k="$2" d="${3:-}"
|
||
local v
|
||
v=$(grep -E "^${k}=" "$p" 2>/dev/null | head -n1 | cut -d'=' -f2- | tr -d '"' | tr -d "'" || true)
|
||
[ -n "$v" ] && printf '%s' "$v" || printf '%s' "$d"
|
||
}
|
||
|
||
# rsync 远端路径转义:含空格目录需单引号包住(现有路径均无空格,此处兜底)
|
||
rsync_remote_path() { # $1 = 远端绝对路径
|
||
local p="$1"
|
||
case "$p" in
|
||
*\ *) printf "'%s'" "$p" ;;
|
||
*) printf '%s' "$p" ;;
|
||
esac
|
||
}
|
||
|
||
# 远端目录列表(仅顶层条目名,用于 scp 目录级增量比对)。
|
||
# 按远端路径形态选择对应的列举命令:
|
||
# - 盘符开头(如 E:/...) → Windows:powershell Get-ChildItem(兼容 cmd/PowerShell 默认 shell)
|
||
# - 否则 → POSIX:ls -1
|
||
# 返回名字列表(每行一个),失败返回空。
|
||
remote_list_dir() { # $1=user $2=ip $3=port $4=ssh_opts $5=远端绝对目录
|
||
local u="$1" ip="$2" port="$3" opts="$4" rdir="$5"
|
||
# 统一 tr -d '\r':Windows powershell/openssh 输出带 CRLF,会让 grep -Fxq 精确匹配
|
||
# 永远失败(已同步目录每次重传)并产生带 CR 的畸形 scp 路径。
|
||
if [[ "${rdir}" =~ ^[A-Za-z]:[\\/] ]]; then
|
||
# Windows 路径:经 powershell 列举,-Name 直接返回每项一行
|
||
ssh -p "${port}" ${opts} "${u}@${ip}" \
|
||
"powershell -NoProfile -Command \"Get-ChildItem -Name -Path '${rdir}'\"" 2>/dev/null | tr -d '\r'
|
||
else
|
||
ssh -p "${port}" ${opts} "${u}@${ip}" "ls -1 '${rdir}'" 2>/dev/null | tr -d '\r'
|
||
fi
|
||
}
|
||
|
||
# scp 单个远端目录到本地,采用"先落地 .partial 再原子 mv"模式:
|
||
# 1. 目标目录 ${dest}/${d} 已存在 → 视为已同步,跳过(保持原增量语义);
|
||
# 但 force=true 时忽略该判断,强制重拉(用于同名目录内容已变更的场景)
|
||
# 2. 否则 scp 远端目录到 ${dest}/${d}.partial
|
||
# 3. scp 成功后 mv 为 ${dest}/${d}(同 dest 文件系统,原子语义)
|
||
# 4. scp 失败 → 清理残留 .partial,return 非零,由调用方决定是否继续
|
||
# 这样即便 scp 传输过程中断,也不会留下"伪完成"目录导致下次被跳过漏传。
|
||
# 强制模式下只在 scp 成功后才清空旧目录再 mv,避免传输失败时丢失旧备份。
|
||
scp_dir() { # $1=u $2=ip $3=port $4=ssh_opts $5=远端根目录 $6=本地目标 $7=目录名 $8=force
|
||
local u="$1" ip="$2" port="$3" opts="$4" rdir="$5" dest="$6" d="$7" force="$8"
|
||
local final="${dest}/${d}"
|
||
if [ "${force}" != "true" ] && [ -e "${final}" ]; then
|
||
return 0
|
||
fi
|
||
local tmp="${dest}/.${d}.partial"
|
||
# 兜底清理可能的历史残留(上次中断留下的半成品)
|
||
rm -rf "${tmp}"
|
||
if scp -P "${port}" ${opts} -r "${u}@${ip}:${rdir}/${d}" "${tmp}"; then
|
||
# 强制模式:先清旧目录再原子 mv(避免 mv 把新目录并入已存在的旧目录)
|
||
[ "${force}" = "true" ] && rm -rf "${final}"
|
||
mv "${tmp}" "${final}"
|
||
else
|
||
echo -e " ${RED}[!]${NC} scp 失败: ${d},清理残留 ${d}.partial"
|
||
rm -rf "${tmp}"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# 传输一个目录树(rsync 优先,退化 scp 目录级增量)
|
||
# $6=可选跳板 ([user@]host[:port],来自 profile 的 REMOTE_PROXY_JUMP):
|
||
# 目标节点不与本机直连、需经中转节点(如 dckj-01 → dckj-02/03)时启用 ProxyJump。
|
||
sync_dir() { # $1=user $2=ip $3=port $4=远端绝对目录 $5=本地目标 $6=jump
|
||
local u="$1" ip="$2" port="$3" rdir="$4" dest="$5" jump="${6:-}"
|
||
local ssh_opts="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_DIR}/cm-${u}@${ip}:${port} -o ControlPersist=1800"
|
||
[ -n "${jump}" ] && ssh_opts="${ssh_opts} -o ProxyJump=${jump}"
|
||
local jump_note=""
|
||
[ -n "${jump}" ] && jump_note="(经跳板 ${jump})"
|
||
|
||
mkdir -p "${dest}"
|
||
|
||
# 连通性与主连接建立(BatchMode=no 允许交互输密码)。
|
||
# 先显式建立 ControlMaster 主连接(-fN 后台保活):跳板机/目标机的密码集中在此
|
||
# 交互输入并各只输一次;成功后后续的探测/rsync/scp 全部复用 socket,不再要密码。
|
||
# 若主连接已存在(如刚同步过其他目录),-O check 直接复用。
|
||
if ! ssh -p "${port}" ${ssh_opts} -O check "${u}@${ip}" >/dev/null 2>&1; then
|
||
echo -e " ${BLUE}[→]${NC} 建立 SSH 主连接${jump_note}(需分别输入跳板机/目标机密码)..."
|
||
if ! ssh -p "${port}" ${ssh_opts} -fN "${u}@${ip}"; then
|
||
echo -e " ${RED}[!]${NC} SSH 主连接建立失败: ${u}@${ip}${jump_note}(密码错误或链路不通),跳过本节点"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# 探测命令用 `echo ok` 而非 `true`:Windows OpenSSH 默认 shell 是 cmd/PowerShell,
|
||
# 没有 Unix 的 true,会导致"密码正确但仍判连接失败"。
|
||
if ! ssh -p "${port}" ${ssh_opts} "${u}@${ip}" "echo ok" >/dev/null 2>&1; then
|
||
echo -e " ${RED}[!] 无法连接 ${u}@${ip},跳过本节点${NC}"
|
||
return 1
|
||
fi
|
||
|
||
# rsync 可用性(本机 + 远端)。远端探测按平台分支:
|
||
# Windows 路径 → powershell Get-Command;POSIX 路径 → command -v
|
||
local rs_ok=false
|
||
if command -v rsync >/dev/null 2>&1; then
|
||
local probe
|
||
if [[ "${rdir}" =~ ^[A-Za-z]:[\\/] ]]; then
|
||
probe="powershell -NoProfile -Command \"Get-Command rsync -ErrorAction SilentlyContinue | Out-Null\""
|
||
else
|
||
probe="command -v rsync"
|
||
fi
|
||
if ssh -p "${port}" ${ssh_opts} "${u}@${ip}" "${probe}" >/dev/null 2>&1; then
|
||
rs_ok=true
|
||
fi
|
||
fi
|
||
|
||
if [ "${rs_ok}" = "true" ]; then
|
||
# rsync 天然增量幂等:每次都跑,自动补齐新增/变更的文件。
|
||
echo -e " ${BLUE}[→]${NC} rsync 增量同步: ${u}@${ip}:${rdir}/ → ${dest}/"
|
||
rsync -a --partial --info=progress2 \
|
||
-e "ssh -p ${port} ${ssh_opts}" \
|
||
"${u}@${ip}:$(rsync_remote_path "${rdir}")/" "${dest}/"
|
||
else
|
||
# 无 rsync(典型 Windows):scp 目录级增量——只拉本地缺失的网格点子目录,
|
||
# 不重复拷贝已存在的目录(避免 win-01 的 52GB 全量重拷)。
|
||
local remote_dirs existing missing
|
||
remote_dirs=$(remote_list_dir "${u}" "${ip}" "${port}" "${ssh_opts}" "${rdir}")
|
||
if [ -z "${remote_dirs}" ]; then
|
||
echo -e " ${YELLOW}[!]${NC} 远端目录 ${rdir} 为空或无法列举,跳过"
|
||
return 1
|
||
fi
|
||
existing=$(ls -1 "${dest}" 2>/dev/null)
|
||
missing=""
|
||
local d
|
||
# 远端目录名白名单校验:scp 路径会把 ${d} 直接拼进 scp 的 remote 路径,
|
||
# 这里只允许字母/数字/._-,禁止 / 与 . .. —— 防御性过滤畸形或注入目录名。
|
||
while IFS= read -r d; do
|
||
[ -n "$d" ] || continue
|
||
case "$d" in
|
||
"."|"..")
|
||
echo -e " ${YELLOW}[!]${NC} 跳过可疑远端目录名: ${d}"
|
||
continue ;;
|
||
esac
|
||
if [[ "$d" == */* ]] || ! [[ "$d" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||
echo -e " ${YELLOW}[!]${NC} 跳过不符合白名单的远端目录名: ${d}"
|
||
continue
|
||
fi
|
||
if [ "${FORCE_REPULL}" = "true" ] || ! grep -Fxq -- "$d" <<<"${existing}"; then
|
||
missing="${missing}${d}"$'\n'
|
||
fi
|
||
done <<<"${remote_dirs}"
|
||
|
||
local total new
|
||
total=$(printf '%s\n' "${remote_dirs}" | grep -c .)
|
||
new=$(printf '%s' "${missing}" | grep -c .)
|
||
if [ "${FORCE_REPULL}" = "true" ]; then
|
||
echo -e " ${YELLOW}[→]${NC} scp 强制重拉: ${u}@${ip}:${rdir}/ 共 ${total} 个,全部重拉 ${new} 个 → ${dest}/"
|
||
else
|
||
echo -e " ${YELLOW}[→]${NC} scp 目录级增量: ${u}@${ip}:${rdir}/ 共 ${total} 个,缺失 ${new} 个 → ${dest}/"
|
||
fi
|
||
if [ "${new}" -eq 0 ]; then
|
||
if [ "${FORCE_REPULL}" = "true" ]; then
|
||
echo -e " ${GREEN}[√]${NC} 强制重拉完成,无远端目录"
|
||
else
|
||
echo -e " ${GREEN}[√]${NC} 本地已是最新,无新增目录"
|
||
fi
|
||
return 0
|
||
fi
|
||
# scp 原子写入:先落地到 .partial 临时目录,成功后同文件系统原子 mv 为最终目录名。
|
||
# 开拷前先清理可能的历史 .partial 残留(上次中断留下的半成品),scp_dir 自身失败
|
||
# 时也会清理各自的 .partial;这样避免下次因"目录已存在"误判为已同步而漏传。
|
||
# (原先用 trap RETURN 兜底,但 RETURN 陷阱会被外层 backup_node 继承,函数返回时
|
||
# dest 已出作用域,触发 set -u 的"未绑定变量"报错,故改为前置清理。)
|
||
rm -rf "${dest}"/.*.partial 2>/dev/null || true
|
||
while IFS= read -r d; do
|
||
[ -n "$d" ] || continue
|
||
if [ "${FORCE_REPULL}" = "true" ]; then
|
||
echo -e " ${YELLOW}[→]${NC} scp 强制重拉: ${d}"
|
||
else
|
||
echo -e " ${YELLOW}[→]${NC} scp 新增: ${d}"
|
||
fi
|
||
scp_dir "${u}" "${ip}" "${port}" "${ssh_opts}" "${rdir}" "${dest}" "$d" "${FORCE_REPULL}"
|
||
done <<<"${missing}"
|
||
fi
|
||
}
|
||
|
||
# 备份单个节点
|
||
backup_node() { # $1 = profile 文件
|
||
local p="$1" node_id role env_mode u ip port dir jump
|
||
node_id=$(read_profile_var "$p" DCTS_NODE_ID)
|
||
role=$(read_profile_var "$p" DEPLOY_ROLE node)
|
||
env_mode=$(read_profile_var "$p" DEPLOY_ENV "")
|
||
u=$(read_profile_var "$p" REMOTE_USER "fmq")
|
||
ip=$(read_profile_var "$p" REMOTE_IP "")
|
||
port=$(read_profile_var "$p" REMOTE_PORT 22)
|
||
dir=$(read_profile_var "$p" REMOTE_DIR "")
|
||
jump=$(read_profile_var "$p" REMOTE_PROXY_JUMP "")
|
||
|
||
# 跳过 server 角色(server 的 result 不是计算产物,且 seeds 另行备份)
|
||
if [ "${role}" = "server" ]; then
|
||
echo -e "${YELLOW}== ${p##*/}: 角色 server,跳过(seed 备份不属本脚本职责)${NC}"
|
||
return 0
|
||
fi
|
||
# 本地节点:result 就在本机 ./data/result,默认不重复备份
|
||
if [ "${env_mode}" = "local" ]; then
|
||
if [ "${INCLUDE_LOCAL}" = "true" ]; then
|
||
# 本机节点 ID 优先取根目录 .env(与节点注册、看板展示的 DCTS_NODE_ID 一致)
|
||
[ -n "${node_id}" ] || node_id=$(grep -E "^DCTS_NODE_ID=" .env 2>/dev/null | head -n1 | cut -d'=' -f2- | tr -d '"' | tr -d "'")
|
||
[ -n "${node_id}" ] || node_id="node-local"
|
||
else
|
||
echo -e "${YELLOW}== ${p##*/}: 本地节点(${node_id:-localhost}),result 已在 ./data/result,跳过(--include-local 可纳入备份树)${NC}"
|
||
return 0
|
||
fi
|
||
fi
|
||
# 按节点过滤
|
||
if [ -n "${ONLY_NODE}" ] && [ "${node_id}" != "${ONLY_NODE}" ]; then
|
||
return 0
|
||
fi
|
||
if [ -z "${node_id}" ]; then
|
||
node_id="${u}@${ip}"
|
||
fi
|
||
|
||
local loc=""
|
||
if [ "${env_mode}" = "local" ]; then
|
||
loc="(本机)"
|
||
else
|
||
loc=" ${u}@${ip}:${dir}"
|
||
fi
|
||
echo -e "\n${GREEN}== ${p##*/} → 节点 ${node_id}${NC} ${loc}"
|
||
|
||
local dest_rc="${BACKUP_ROOT}/${node_id}/result"
|
||
if [ "${env_mode}" = "local" ]; then
|
||
# 本机节点:优先 rsync 增量(避免每次 cp -a 全量重拷),无 rsync 才退化 cp -a。
|
||
echo -e " ${BLUE}[→]${NC} 本机复制: ./data/result → ${dest_rc}"
|
||
mkdir -p "${dest_rc}"
|
||
if command -v rsync >/dev/null 2>&1; then
|
||
rsync -a --info=progress2 ./data/result/ "${dest_rc}/"
|
||
else
|
||
cp -a ./data/result/. "${dest_rc}/"
|
||
fi
|
||
else
|
||
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/result" "${dest_rc}" "${jump}" || return 1
|
||
fi
|
||
echo -e " ${GREEN}[√]${NC} result 已同步,共 $(ls -1 "${dest_rc}" 2>/dev/null | grep -c . || true) 个网格点子目录"
|
||
|
||
# 可选:一并拉取 data/work 沙盒残留(未清理的计算现场,含完整过程文件)
|
||
if [ "${WITH_WORK}" = "true" ]; then
|
||
local dest_wk="${BACKUP_ROOT}/${node_id}/work"
|
||
if [ "${env_mode}" = "local" ]; then
|
||
echo -e " ${BLUE}[→]${NC} 本机复制: ./data/work → ${dest_wk}"
|
||
mkdir -p "${dest_wk}"
|
||
if command -v rsync >/dev/null 2>&1; then
|
||
rsync -a ./data/work/ "${dest_wk}/" 2>/dev/null || true
|
||
else
|
||
cp -a ./data/work/. "${dest_wk}/" 2>/dev/null || true
|
||
fi
|
||
else
|
||
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/work" "${dest_wk}" "${jump}" || true
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# =============================================================================
|
||
# 交互式选择(无参数或 -i 时进入,风格对齐 deploy.sh 的 Profile 向导)
|
||
# =============================================================================
|
||
interactive_select() {
|
||
# 构建可同步列表:非 *.example 且 DEPLOY_ROLE=node 的 profile(server 不在菜单)
|
||
local menu=()
|
||
for p in deploy.env.d/*.env; do
|
||
[ -f "$p" ] || continue
|
||
[[ "$p" != *.example ]] || continue
|
||
if [ "$(read_profile_var "$p" DEPLOY_ROLE node)" = "node" ]; then
|
||
menu+=("$p")
|
||
fi
|
||
done
|
||
if [ ${#menu[@]} -eq 0 ]; then
|
||
echo -e "${RED}错误: deploy.env.d/ 下没有可用的 node profile${NC}"
|
||
exit 1
|
||
fi
|
||
|
||
echo -e "${BLUE}==============================================================${NC}"
|
||
echo -e "${CYAN} 🚀 DCTS 计算结果产物下载控制台 ${NC}"
|
||
echo -e "${BLUE}==============================================================${NC}"
|
||
echo -e "${YELLOW}【节点选择】检测到 ${#menu[@]} 个可用的计算节点 Profile 文件:${NC}"
|
||
local i p node_id env_mode ip user
|
||
i=1
|
||
for p in "${menu[@]}"; do
|
||
node_id=$(read_profile_var "$p" DCTS_NODE_ID)
|
||
env_mode=$(read_profile_var "$p" DEPLOY_ENV "")
|
||
ip=$(read_profile_var "$p" REMOTE_IP "")
|
||
user=$(read_profile_var "$p" REMOTE_USER "fmq")
|
||
[ -n "$node_id" ] || node_id="${user}@${ip:-localhost}"
|
||
local tag="远程" ipshow="$ip"
|
||
if [ "${env_mode}" = "local" ]; then tag="本机"; ipshow="(本机)"; fi
|
||
printf " %d) %s (%s) %s@%s [%s]\n" "$i" "$node_id" "${p##*/}" "$user" "$ipshow" "$tag"
|
||
((i++))
|
||
done
|
||
echo -e " a) [全部节点] 依次同步上述全部 ${#menu[@]} 个节点"
|
||
read -r -p "请选择节点 [序号如 1,2 或 1-2; 全部如 a; 默认 0=退出]: " CHOICE
|
||
CHOICE=${CHOICE:-0}
|
||
|
||
SELECTED_PROFILES=()
|
||
if [[ "${CHOICE,,}" == "a" ]] || [[ "${CHOICE,,}" == "all" ]]; then
|
||
SELECTED_PROFILES=("${menu[@]}")
|
||
elif [[ "${CHOICE}" =~ ^[0-9] ]]; then
|
||
local part from to n
|
||
IFS=',' read -r -a ADDR <<< "${CHOICE}"
|
||
for part in "${ADDR[@]}"; do
|
||
if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
|
||
from="${BASH_REMATCH[1]}"; to="${BASH_REMATCH[2]}"
|
||
for ((n=from; n<=to; n++)); do
|
||
if [ "$n" -ge 1 ] && [ "$n" -le "${#menu[@]}" ]; then
|
||
SELECTED_PROFILES+=("${menu[$((n-1))]}")
|
||
fi
|
||
done
|
||
elif [[ "$part" =~ ^[0-9]+$ ]]; then
|
||
if [ "$part" -ge 1 ] && [ "$part" -le "${#menu[@]}" ]; then
|
||
SELECTED_PROFILES+=("${menu[$((part-1))]}")
|
||
fi
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [ ${#SELECTED_PROFILES[@]} -eq 0 ]; then
|
||
echo -e "${YELLOW}未选择任何节点,退出。${NC}"
|
||
exit 0
|
||
fi
|
||
|
||
# 是否连带同步 data/work 沙盒残留
|
||
if [ "${WITH_WORK}" != "true" ]; then
|
||
read -r -p "是否连带同步 data/work 沙盒残留(未清理的计算现场,含完整过程文件)? [y/N]: " WK_CHOICE
|
||
[[ "${WK_CHOICE,,}" == "y" || "${WK_CHOICE,,}" == "yes" ]] && WITH_WORK=true
|
||
fi
|
||
|
||
# Windows(scp)节点是否强制重拉:本地即使已有同名目录也全部重新下载覆盖
|
||
if [ "${FORCE_REPULL}" != "true" ]; then
|
||
read -r -p "是否强制重拉(忽略本地已有同名目录,全部重新 scp 覆盖,仅对 Windows/scp 节点生效)? [y/N]: " FORCE_CHOICE
|
||
[[ "${FORCE_CHOICE,,}" == "y" || "${FORCE_CHOICE,,}" == "yes" ]] && FORCE_REPULL=true
|
||
fi
|
||
|
||
# 用户显式勾选本机节点 → 自动纳入备份树(否则 backup_node 会跳过本机)
|
||
local p2 env2
|
||
for p2 in "${SELECTED_PROFILES[@]}"; do
|
||
env2=$(read_profile_var "$p2" DEPLOY_ENV "")
|
||
[ "${env2}" = "local" ] && INCLUDE_LOCAL=true
|
||
done
|
||
|
||
echo -e "${CYAN}将同步 ${#SELECTED_PROFILES[@]} 个节点 → ${BACKUP_ROOT}${NC}"
|
||
}
|
||
|
||
if [ "${HAD_ARGS}" = "false" ] || [ "${INTERACTIVE}" = "true" ]; then
|
||
interactive_select
|
||
fi
|
||
|
||
# =============================================================================
|
||
# 主流程:遍历 deploy.env.d/*.env(排除 *.example)
|
||
# =============================================================================
|
||
echo -e "${CYAN}备份根目录: ${BACKUP_ROOT}${NC}"
|
||
echo -e "${CYAN}SSH 复用目录: ${SSH_CONTROL_DIR}${NC}"
|
||
mkdir -p "${SSH_CONTROL_DIR}"
|
||
|
||
profiles=()
|
||
for p in deploy.env.d/*.env; do
|
||
[ -f "$p" ] || continue
|
||
[[ "$p" != *.example ]] || continue
|
||
profiles+=("$p")
|
||
done
|
||
|
||
if [ ${#profiles[@]} -eq 0 ]; then
|
||
echo -e "${RED}错误: deploy.env.d/ 下没有可用的 node profile${NC}"
|
||
exit 1
|
||
fi
|
||
|
||
# 交互式模式下仅同步用户勾选的节点
|
||
if [ ${#SELECTED_PROFILES[@]} -gt 0 ]; then
|
||
profiles=("${SELECTED_PROFILES[@]}")
|
||
fi
|
||
|
||
for p in "${profiles[@]}"; do
|
||
backup_node "$p" || true
|
||
done
|
||
|
||
echo -e "\n${GREEN}===== 全部节点处理完成,备份位于 ${BACKUP_ROOT} =====${NC}"
|