Files
DCTS/scripts/fetch_results.sh
T
fmq b058e66722 feat(all): NaN 伪收敛否决与 nl_tight 能量回退、emflux 检验 4π 修正、nst 行宽与种子边界修复、gfATO 谱线表接通与网格加密 9216 点、阶段分项统计与跳板机部署
物理修复(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 篇根因分析/验证文档
2026-08-17 23:55:26 +08:00

455 lines
20 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
# ==============================================================================
# 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:/...) → Windowspowershell Get-ChildItem(兼容 cmd/PowerShell 默认 shell
# - 否则 → POSIXls -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-CommandPOSIX 路径 → 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 的 profileserver 不在菜单)
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}"