#!/usr/bin/env bash set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) cd "$ROOT_DIR" REPO_NAME="focus" REMOTE_HOST="ubuntu@43.163.240.46" REMOTE_DIR="/home/ubuntu/repo/$REPO_NAME" CF_ENV_FILE="$HOME/.pi/agent/skills/cloudflare/.env" # 1. 本地单飞锁:使用稳定且权限安全的单一锁文件,避免多任务并发与扫描竞态 LOCAL_LOCK_FILE=$(git rev-parse --path-format=absolute --git-path focus-deploy.lock 2>/dev/null || true) if [[ -z "$LOCAL_LOCK_FILE" ]]; then LOCAL_LOCK_PATH=$(git rev-parse --git-path focus-deploy.lock 2>/dev/null || true) if [[ -n "$LOCAL_LOCK_PATH" ]]; then if [[ "$LOCAL_LOCK_PATH" = /* ]]; then LOCAL_LOCK_FILE="$LOCAL_LOCK_PATH" else LOCAL_LOCK_FILE="$ROOT_DIR/$LOCAL_LOCK_PATH" fi else LOCAL_LOCK_FILE="$ROOT_DIR/.git/focus-deploy.lock" fi fi mkdir -p "$(dirname "$LOCAL_LOCK_FILE")" exec 9>"$LOCAL_LOCK_FILE" if ! flock -n 9; then echo "已有本地部署任务在运行,请稍候" >&2 exit 1 fi # 2. 创建私有临时目录并注册清理 trap(避免固定 /tmp 路径的 symlink 与权限冲突) TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/focus-deploy.XXXXXX") chmod 700 "$TMP_DIR" SSH_SOCKET="$TMP_DIR/ssh.sock" COORD_PID="" COORD_IN_FD_OPEN="" COORD_OUT_FD_OPEN="" REMOTE_SWITCH_FILE="" REMOTE_STAGING_DIR="" SWITCH_CALLED="" SWITCH_COMPLETED="" CF_PURGE_SUCCESS=0 cleanup() { local exit_code=$? local i trap - EXIT INT TERM exec 9>&- 2>/dev/null || true if [[ -n "${COORD_IN_FD_OPEN:-}" ]]; then exec 3>&- 2>/dev/null || true COORD_IN_FD_OPEN="" fi if [[ -n "${COORD_OUT_FD_OPEN:-}" ]]; then exec 4<&- 2>/dev/null || true COORD_OUT_FD_OPEN="" fi if [[ -n "${COORD_PID:-}" ]]; then kill "$COORD_PID" 2>/dev/null || true for (( i = 0; i < 20; i++ )); do kill -0 "$COORD_PID" 2>/dev/null || break sleep 0.1 done kill -9 "$COORD_PID" 2>/dev/null || true wait "$COORD_PID" 2>/dev/null || true COORD_PID="" fi # 部署未成功时保留远端 staging 目录与 helper 脚本供排查;全部验证成功后若仍有残留 helper 脚本则清理 if [[ -z "${SWITCH_COMPLETED:-}" ]]; then if [[ -n "${REMOTE_STAGING_DIR:-}" || -n "${REMOTE_SWITCH_FILE:-}" ]]; then echo "提示:部署未成功,远端 staging (${REMOTE_STAGING_DIR:-none}) 与切换脚本 (${REMOTE_SWITCH_FILE:-none}) 已保留" >&2 fi elif [[ -n "${REMOTE_SWITCH_FILE:-}" && -e "${SSH_SOCKET:-}" ]]; then timeout 5s ssh -S "$SSH_SOCKET" -o "ConnectTimeout=5" "$REMOTE_HOST" \ "rm -f -- '$REMOTE_DIR/$REMOTE_SWITCH_FILE'" 2>/dev/null || true fi if [[ -n "${SSH_SOCKET:-}" && -e "$SSH_SOCKET" ]]; then timeout 10s ssh -o ControlPath="$SSH_SOCKET" -O exit "$REMOTE_HOST" 2>/dev/null || true fi if [[ -n "${TMP_DIR:-}" && -d "$TMP_DIR" ]]; then rm -rf -- "$TMP_DIR" fi exit "$exit_code" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM read_dotenv_value() { local wanted_key="$1" local line key value result="" [[ -r "$CF_ENV_FILE" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ "$line" == export\ * ]] && line=${line#export } [[ "$line" == *=* ]] || continue key=${line%%=*} key="${key#"${key%%[![:space:]]*}"}" key="${key%"${key##*[![:space:]]}"}" [[ "$key" == "$wanted_key" ]] || continue value=${line#*=} value=${value%$'\r'} value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" if [[ "$value" == \"*\" && ${#value} -ge 2 ]]; then value=${value:1:${#value}-2} elif [[ "$value" == \'*\' && ${#value} -ge 2 ]]; then value=${value:1:${#value}-2} fi result=$value done < "$CF_ENV_FILE" printf '%s' "$result" } API_TOKEN=${CF_API_TOKEN:-${CLOUDFLARE_API_TOKEN:-}} ZONE_ID=${CF_ZONE_ID:-${CLOUDFLARE_ZONE_ID:-}} if [[ -z "$API_TOKEN" ]]; then API_TOKEN=$(read_dotenv_value CLOUDFLARE_API_TOKEN) fi if [[ -z "$ZONE_ID" ]]; then ZONE_ID=$(read_dotenv_value CLOUDFLARE_ZONE_ID) fi if [[ -z "$API_TOKEN" || -z "$ZONE_ID" ]]; then echo "缺少 Cloudflare API token 或 zone ID;请设置 CF_* / CLOUDFLARE_* 环境变量或配置 $CF_ENV_FILE" >&2 exit 1 fi echo "==> 本地构建前端产物" npm run build for file in index.html favicon.svg favicon.ico favicon-16x16.png favicon-32x32.png apple-touch-icon.png manifest.json pwa-192.png pwa-512.png; do if [[ ! -s "$ROOT_DIR/dist/$file" ]]; then echo "构建产物缺失或为空:dist/$file" >&2 exit 1 fi done # 确保 dist 目录不包含任何构建元数据或部署脚本(避免敏感运维文件对外发布) rm -f "$ROOT_DIR/dist/.build_info" "$ROOT_DIR/dist/.deploy_switch.sh" echo "==> 计算各静态资源独立版本并注入 cache-bust 查询参数" VERSIONS_FILE="$TMP_DIR/icon_versions.env" python3 -I - "$ROOT_DIR/dist" "$VERSIONS_FILE" <<'PY' import hashlib import json import os import re import sys dist_dir = sys.argv[1] versions_file = sys.argv[2] def get_hash(filename): filepath = os.path.join(dist_dir, filename) if not os.path.isfile(filepath): raise SystemExit(f"缺少产物文件: {filepath}") with open(filepath, "rb") as f: data = f.read() if not data: raise SystemExit(f"产物文件为空: {filepath}") return hashlib.sha256(data).hexdigest()[:12] # 1. 计算所有独立静态图标资源的哈希 initial_icon_map = { "ICON_VERSION_SVG": "favicon.svg", "ICON_VERSION_ICO": "favicon.ico", "ICON_VERSION_FAV16": "favicon-16x16.png", "ICON_VERSION_FAV32": "favicon-32x32.png", "ICON_VERSION_APPLE": "apple-touch-icon.png", "ICON_VERSION_PWA192": "pwa-192.png", "ICON_VERSION_PWA512": "pwa-512.png", } versions = {} for var_name, fname in initial_icon_map.items(): h = get_hash(fname) if not re.fullmatch(r"[0-9a-f]{12}", h): raise SystemExit(f"计算哈希格式异常: {fname} -> {h}") versions[var_name] = h # 2. 改写 dist/manifest.json:在 icons.src 中分别加入对应 PWA hash 查询参数 manifest_path = os.path.join(dist_dir, "manifest.json") with open(manifest_path, "r", encoding="utf-8") as f: manifest_data = json.load(f) icons = manifest_data.get("icons", []) if not isinstance(icons, list): raise SystemExit("dist/manifest.json 中的 icons 字段非列表格式") expected_pwa_map = { "/pwa-192.png": versions["ICON_VERSION_PWA192"], "/pwa-512.png": versions["ICON_VERSION_PWA512"], } pwa_match_counts = {k: 0 for k in expected_pwa_map} for icon in icons: if not isinstance(icon, dict): continue src = icon.get("src", "") norm_src = src if src.startswith("/") else f"/{src}" if norm_src in expected_pwa_map: if "?" in src: raise SystemExit(f"dist/manifest.json 中图标 {src} 已包含查询参数") pwa_version = expected_pwa_map[norm_src] icon["src"] = f"{src}?v={pwa_version}" pwa_match_counts[norm_src] += 1 for norm_src, count in pwa_match_counts.items(): if count != 1: raise SystemExit( f"dist/manifest.json 预期图标 {norm_src} 改写匹配次数异常(预期 1 次,实际 {count} 次)" ) manifest_text = json.dumps(manifest_data, indent=2, ensure_ascii=False) + "\n" for norm_src, pwa_version in expected_pwa_map.items(): bare_src = norm_src.lstrip("/") verify_pwa = re.compile(rf'["\']/?{re.escape(bare_src)}\?v={re.escape(pwa_version)}["\']') if len(verify_pwa.findall(manifest_text)) != 1: raise SystemExit(f"dist/manifest.json 图标 {norm_src} 版本化校验失败") with open(manifest_path, "w", encoding="utf-8") as f: f.write(manifest_text) # 3. 写回最终 manifest 后再计算其 hash,供 index 指向,避免其 hash stale manifest_hash = get_hash("manifest.json") if not re.fullmatch(r"[0-9a-f]{12}", manifest_hash): raise SystemExit(f"计算 manifest 哈希格式异常: {manifest_hash}") versions["ICON_VERSION_MANIFEST"] = manifest_hash # 4. 改写 dist/index.html:每个 pathname 使用该资源自己的 hash html_path = os.path.join(dist_dir, "index.html") with open(html_path, "r", encoding="utf-8") as f: html = f.read() html_targets = [ ("/favicon.svg", versions["ICON_VERSION_SVG"]), ("/favicon.ico", versions["ICON_VERSION_ICO"]), ("/favicon-16x16.png", versions["ICON_VERSION_FAV16"]), ("/favicon-32x32.png", versions["ICON_VERSION_FAV32"]), ("/apple-touch-icon.png", versions["ICON_VERSION_APPLE"]), ("/manifest.json", versions["ICON_VERSION_MANIFEST"]), ] for target, res_version in html_targets: bare_target = target.lstrip("/") # 验证目标资源尚未包含 query 参数,避免产生重复 query existing_query_pattern = re.compile( rf'(?:href|src)=["\']/?{re.escape(bare_target)}\?[^"\']*["\']' ) if existing_query_pattern.search(html): raise SystemExit(f"dist/index.html 中目标资源 {target} 已存在查询参数") pattern = re.compile( rf'((?:href|src)=["\']/?{re.escape(bare_target)})(["\'])' ) matches = pattern.findall(html) if len(matches) != 1: raise SystemExit( f"dist/index.html 中目标资源 {target} 匹配次数异常(预期 1 次,实际匹配 {len(matches)} 次)" ) html = pattern.sub(rf'\g<1>?v={res_version}\g<2>', html) for target, res_version in html_targets: bare_target = target.lstrip("/") verify_pattern = re.compile( rf'(?:href|src)=["\']/?{re.escape(bare_target)}\?v={re.escape(res_version)}["\']' ) if len(verify_pattern.findall(html)) != 1: raise SystemExit(f"dist/index.html 中目标资源 {target} 版本化校验失败") dup_query_pattern = re.compile( rf'(?:href|src)=["\']/?{re.escape(bare_target)}\?v={re.escape(res_version)}[?&][^"\']*["\']' ) if dup_query_pattern.search(html): raise SystemExit(f"dist/index.html 中目标资源 {target} 检测到重复 query 参数") with open(html_path, "w", encoding="utf-8") as f: f.write(html) # 5. 输出 key=value 版本映射给 shell 安全读取 expected_keys = [ "ICON_VERSION_SVG", "ICON_VERSION_ICO", "ICON_VERSION_FAV16", "ICON_VERSION_FAV32", "ICON_VERSION_APPLE", "ICON_VERSION_MANIFEST", "ICON_VERSION_PWA192", "ICON_VERSION_PWA512", ] with open(versions_file, "w", encoding="utf-8") as f: for k in expected_keys: v = versions.get(k) if not v or not re.fullmatch(r"[0-9a-f]{12}", v): raise SystemExit(f"缺少有效版本键值: {k}={v}") f.write(f"{k}={v}\n") print("产物缓存穿透版本注入成功:各资源已分别注入独立内容哈希") PY while IFS='=' read -r key val || [[ -n "$key" ]]; do [[ -z "$key" ]] && continue case "$key" in ICON_VERSION_SVG|ICON_VERSION_ICO|ICON_VERSION_FAV16|ICON_VERSION_FAV32|ICON_VERSION_APPLE|ICON_VERSION_MANIFEST|ICON_VERSION_PWA192|ICON_VERSION_PWA512) if [[ "$val" =~ ^[0-9a-f]{12}$ ]]; then printf -v "$key" '%s' "$val" else echo "非法版本哈希值: $key=$val" >&2 exit 1 fi ;; *) echo "未知版本键名: $key" >&2 exit 1 ;; esac done < "$VERSIONS_FILE" for var in ICON_VERSION_SVG ICON_VERSION_ICO ICON_VERSION_FAV16 ICON_VERSION_FAV32 ICON_VERSION_APPLE ICON_VERSION_MANIFEST ICON_VERSION_PWA192 ICON_VERSION_PWA512; do if [[ -z "${!var:-}" ]]; then echo "缺少必要版本变量: $var" >&2 exit 1 fi done echo "各资源版本哈希:" echo " favicon.svg: $ICON_VERSION_SVG" echo " favicon.ico: $ICON_VERSION_ICO" echo " favicon-16x16.png: $ICON_VERSION_FAV16" echo " favicon-32x32.png: $ICON_VERSION_FAV32" echo " apple-touch-icon: $ICON_VERSION_APPLE" echo " manifest.json: $ICON_VERSION_MANIFEST" echo " pwa-192.png: $ICON_VERSION_PWA192" echo " pwa-512.png: $ICON_VERSION_PWA512" # 生成安全纯数字标识,用于远端 helper 脚本与独立 staging 目录命名 BUILD_TIME=$(date +%s) DEPLOY_PID=$$ DEPLOY_RAND=$RANDOM if [[ ! "$BUILD_TIME" =~ ^[0-9]+$ || ! "$DEPLOY_PID" =~ ^[0-9]+$ || ! "$DEPLOY_RAND" =~ ^[0-9]+$ ]]; then echo "部署标识生成异常,包含非法字符" >&2 exit 1 fi REMOTE_SWITCH_FILE=".deploy-switch-${BUILD_TIME}-${DEPLOY_PID}.sh" REMOTE_SWITCH_PATH="$REMOTE_DIR/$REMOTE_SWITCH_FILE" STAGING_DIR="dist.next.${BUILD_TIME}.${DEPLOY_PID}.${DEPLOY_RAND}" REMOTE_STAGING_DIR="$STAGING_DIR" # 3. 建立本地 SSH ControlMaster 复用连接 echo "==> 建立 SSH 连接并获取远端排他部署锁" ssh -M -S "$SSH_SOCKET" -o "ControlPersist=300" -o "BatchMode=yes" -o "ConnectTimeout=10" -f -N "$REMOTE_HOST" ssh -S "$SSH_SOCKET" -o "ConnectTimeout=10" "$REMOTE_HOST" "mkdir -p -- '$REMOTE_DIR'" # 4. 启动远端持锁部署协调器(跨 staging 上传、目录切换、purge、公网验证全程持锁) mkfifo "$TMP_DIR/coord_in" mkfifo "$TMP_DIR/coord_out" ssh -S "$SSH_SOCKET" "$REMOTE_HOST" \ "bash -s" \ < "$TMP_DIR/coord_in" > "$TMP_DIR/coord_out" 2>&1 & COORD_PID=$! exec 3>"$TMP_DIR/coord_in" COORD_IN_FD_OPEN=1 exec 4<"$TMP_DIR/coord_out" COORD_OUT_FD_OPEN=1 # 发送初始化握手并在远端获取排他部署锁与注册锁释放 trap cat <&3 set -euo pipefail REMOTE_DIR='$REMOTE_DIR' REMOTE_LOCK_FD=200 CURRENT_SWITCH_HELPER="" eval "exec \${REMOTE_LOCK_FD}>'$REMOTE_DIR/.deploy.lock'" if ! flock -w 120 -x "\${REMOTE_LOCK_FD}"; then echo "COORD_LOCK_TIMEOUT" exit 1 fi cleanup_coord() { local code=\$? trap - EXIT INT TERM HUP if [[ -n "\${CURRENT_SWITCH_HELPER:-}" ]]; then if [[ "\$code" -eq 0 ]]; then rm -f -- "\${CURRENT_SWITCH_HELPER}" 2>/dev/null || true CURRENT_SWITCH_HELPER="" else echo "提示:远端协调器异常或失败退出,已保留切换脚本: \${CURRENT_SWITCH_HELPER}" >&2 fi fi flock -u "\${REMOTE_LOCK_FD}" 2>/dev/null || true eval "exec \${REMOTE_LOCK_FD}>&-" 2>/dev/null || true exit "\$code" } trap cleanup_coord EXIT trap 'exit 130' INT trap 'exit 143' TERM trap 'exit 129' HUP echo "COORD_LOCKED" HANDSHAKE_EOF coord_status="" if ! read -r -t 130 coord_status <&4 || [[ "$coord_status" != "COORD_LOCKED" ]]; then echo "获取远端部署锁失败,可能有其他部署任务正在运行或网络连接异常" >&2 exit 1 fi echo "已获取远端排他部署锁(部署串行化由锁获取顺序保证,覆盖 staging 同步、目录切换、Cloudflare purge、公网验证全程)" # 生成远端原子切换脚本(置于临时目录,禁止写入 dist,杜绝公网意外暴露) SWITCH_LOCAL_SCRIPT="$TMP_DIR/deploy-switch.sh" cat > "$SWITCH_LOCAL_SCRIPT" << 'SWITCH_SCRIPT_EOF' #!/usr/bin/env bash set -euo pipefail REMOTE_DIR="/home/ubuntu/repo/focus" STAGING_DIR="$1" cd "$REMOTE_DIR" if [[ -L "$STAGING_DIR" || ! -d "$STAGING_DIR" || ! -s "$STAGING_DIR/index.html" ]]; then echo "远端 staging 缺少有效 index.html 或为符号链接:$STAGING_DIR" >&2 exit 1 fi exchange_dirs() { local dir_a="$1" local dir_b="$2" python3 -I - "$dir_a" "$dir_b" <<'PY' import ctypes import os import sys AT_FDCWD = -100 RENAME_EXCHANGE = 2 libc = ctypes.CDLL(None, use_errno=True) try: renameat2 = libc.renameat2 except AttributeError as exc: raise SystemExit("libc does not provide renameat2") from exc renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] renameat2.restype = ctypes.c_int if renameat2(AT_FDCWD, os.fsencode(sys.argv[1]), AT_FDCWD, os.fsencode(sys.argv[2]), RENAME_EXCHANGE) != 0: error = ctypes.get_errno() raise OSError(error, os.strerror(error)) PY } origin_is_healthy() { local response response=$(curl -fsS --connect-timeout 3 --max-time 10 -H 'Host: focus.102434.xyz' http://127.0.0.1/) || return 1 [[ "$response" == *'id="root"'* ]] } if [[ -e dist || -L dist ]]; then if [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then echo "线上 dist 为符号链接或缺少有效 index.html,拒绝切换" >&2 exit 1 fi if ! exchange_dirs dist "$STAGING_DIR"; then echo "原子交换失败,线上 dist 未切换;保留 staging: $STAGING_DIR" >&2 exit 1 fi if ! origin_is_healthy || [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then echo "源站健康检查失败,正在回滚" >&2 if exchange_dirs dist "$STAGING_DIR"; then echo "已恢复上一版 dist;失败版本保留在 $STAGING_DIR" >&2 else echo "严重告警:回滚交换失败!两份目录均已保留在 dist 与 $STAGING_DIR" >&2 fi exit 1 fi # 线上新版本健康检查通过后,归档上一版本(保证 dist.previous 确实是本次 deploy 前的线上版本) archive_ok=0 if [[ ! -e dist.previous && ! -L dist.previous ]]; then if mv -- "$STAGING_DIR" dist.previous; then archive_ok=1 else echo "归档失败:mv $STAGING_DIR dist.previous 失败" >&2 fi elif [[ -L dist.previous || ! -d dist.previous || ! -s dist.previous/index.html ]]; then echo "归档失败:既有 dist.previous 为符号链接或非有效目录(缺少 index.html),禁止作为自动交换与回滚目标" >&2 archive_ok=0 else # 存在有效 previous,原子 exchange staging 和 dist.previous,再尽力删除 staging 中更老备份 if exchange_dirs "$STAGING_DIR" dist.previous; then archive_ok=1 # 交换后 dist.previous 为刚才的 old-live,STAGING_DIR 为老 previous rm -rf -- "$STAGING_DIR" 2>/dev/null || true else echo "归档失败:原子交换 STAGING_DIR 与 dist.previous 失败" >&2 fi fi if [[ "$archive_ok" -ne 1 ]]; then echo "归档本次旧版本失败,立即回滚至刚才的线上版本..." >&2 if exchange_dirs dist "$STAGING_DIR"; then if origin_is_healthy && [[ ! -L dist && -d dist && -s dist/index.html ]]; then echo "已成功回滚至刚才旧版本,源站健康检查通过;保留既有 dist.previous,中止部署" >&2 exit 1 else echo "严重告警:已回滚至旧版本,但源站健康检查未通过!两份目录均已保留在 dist 与 $STAGING_DIR" >&2 exit 1 fi else echo "严重告警:归档失败且回滚至旧版本失败!两份目录均已保留在 dist 与 $STAGING_DIR" >&2 exit 1 fi fi else # 首次部署 if [[ -L "$STAGING_DIR" || ! -d "$STAGING_DIR" || ! -s "$STAGING_DIR/index.html" ]]; then echo "初次部署 staging 缺少有效 index.html 或为符号链接:$STAGING_DIR" >&2 exit 1 fi if ! python3 -I - "$STAGING_DIR" <<'PY' import os import sys os.rename(sys.argv[1], "dist") PY then echo "初次部署目录切换失败;staging 已保留:$STAGING_DIR" >&2 exit 1 fi if ! origin_is_healthy || [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then echo "初次部署源站健康检查失败,正在撤回新目录" >&2 if python3 -I - "$STAGING_DIR" <<'PY' import os import sys os.rename("dist", sys.argv[1]) PY then echo "新目录已退回 staging:$STAGING_DIR,保留供排查" >&2 else echo "严重告警:撤回失败;线上目录仍保留,请检查 dist 和 $STAGING_DIR" >&2 fi exit 1 fi fi echo "源站健康检查通过" SWITCH_SCRIPT_EOF chmod 700 "$SWITCH_LOCAL_SCRIPT" echo "==> 同步到远端 staging: $STAGING_DIR" ssh -S "$SSH_SOCKET" -o "ConnectTimeout=10" "$REMOTE_HOST" "mkdir -m 755 -p -- '$REMOTE_DIR/$STAGING_DIR'" rsync -az --timeout=60 --delete -e "ssh -S $SSH_SOCKET -o ConnectTimeout=10" -- "$ROOT_DIR/dist/" "$REMOTE_HOST:$REMOTE_DIR/$STAGING_DIR/" echo "==> 上传远端原子切换脚本: $REMOTE_SWITCH_FILE" scp -o ControlPath="$SSH_SOCKET" -o "ConnectTimeout=10" -p "$SWITCH_LOCAL_SCRIPT" "$REMOTE_HOST:$REMOTE_SWITCH_PATH" ssh -S "$SSH_SOCKET" -o "ConnectTimeout=10" "$REMOTE_HOST" "chmod 700 '$REMOTE_SWITCH_PATH'" echo "==> 远端原子切换线上目录" SWITCH_CALLED=1 cat <&3 CURRENT_SWITCH_HELPER='$REMOTE_SWITCH_PATH' chmod 700 "\$CURRENT_SWITCH_HELPER" if bash "\$CURRENT_SWITCH_HELPER" '$STAGING_DIR'; then echo "SWITCH_OK" else echo "SWITCH_FAIL" exit 1 fi SW_CMD switch_status="" while true; do if ! IFS= read -r -t 30 line <&4; then echo "读取远端切换输出超时或连接异常中断(30s 无响应)" >&2 exit 1 fi if [[ "$line" == "SWITCH_OK" ]]; then switch_status="OK" break elif [[ "$line" == "SWITCH_FAIL" ]]; then switch_status="FAIL" break else echo " [remote] $line" fi done if [[ "$switch_status" != "OK" ]]; then echo "线上目录切换失败或健康检查未通过" >&2 exit 1 fi # SWITCH_COMPLETED 移至全部公网验收通过后再置位,确保任一公网检查失败均能正确触发回滚与失败保护 echo "==> 精确清理 Cloudflare 缓存" CF_CURL_CONFIG="$TMP_DIR/cf-curl.conf" install -m 600 /dev/null "$CF_CURL_CONFIG" cat > "$CF_CURL_CONFIG" <" with open(out_file, "w", encoding="utf-8") as out: out.write(f"FAIL\t无效 JSON 响应 (HTTP {status}): {snippet}") sys.exit(0) if not isinstance(response, dict): with open(out_file, "w", encoding="utf-8") as out: out.write(f"FAIL\t非字典 JSON 响应 (HTTP {status})") sys.exit(0) if 200 <= status < 300 and response.get("success") is True: with open(out_file, "w", encoding="utf-8") as out: out.write("OK\tCloudflare 精准缓存清理成功") sys.exit(0) err_items = [] errors = response.get("errors") if isinstance(errors, list): for err in errors: if isinstance(err, dict): code = err.get("code") code_str = f"[{code}] " if code is not None else "" msg = err.get("message", str(err)) err_items.append(f"{code_str}{msg}") elif err: err_items.append(str(err)) elif errors: err_items.append(str(errors)) messages = response.get("messages") if isinstance(messages, list): for msg in messages: if isinstance(msg, dict): code = msg.get("code") code_str = f"[{code}] " if code is not None else "" text = msg.get("message", str(msg)) err_items.append(f"{code_str}{text}") elif msg: err_items.append(str(msg)) elif messages: err_items.append(str(messages)) details = "; ".join(err_items) if err_items else "无详细错误信息" with open(out_file, "w", encoding="utf-8") as out: out.write(f"FAIL\tHTTP {status}: {details}") PY purge_code="" purge_msg="" IFS=$'\t' read -r purge_code purge_msg < "$PURGE_PARSE_RESULT" || true if [[ -z "$purge_code" ]]; then purge_code="FAIL" purge_msg="结果解析异常" fi if [[ "$purge_code" == "OK" ]]; then CF_PURGE_SUCCESS=1 echo "Cloudflare 精准缓存清理成功 (已清理 10 个指定 URL)" else CF_PURGE_SUCCESS=0 echo "警告:Cloudflare purge 失败 ($purge_msg)" >&2 echo "警告:进入显式 cache-bust fallback 模式,线上访问依靠各资源版本化 URL 命中最新产物(未声称 purge 成功)" >&2 fi check_public_resource() { local path="$1" local expected_type="$2" local local_file="${3:-}" local body_file metadata status content_type mime local_hash remote_hash PUBLIC_CHECK_COUNT=$((PUBLIC_CHECK_COUNT + 1)) body_file="$TMP_DIR/public-$PUBLIC_CHECK_COUNT" if ! metadata=$(curl --location --silent --show-error --connect-timeout 10 --max-time 30 \ -o "$body_file" -w $'%{http_code}\n%{content_type}' "https://focus.102434.xyz$path"); then echo "公网请求失败:$path" >&2 return 1 fi status=${metadata%%$'\n'*} content_type=${metadata#*$'\n'} if [[ "$status" != 200 ]]; then echo "公网状态码异常:$path 返回 $status" >&2 return 1 fi mime=${content_type%%;*} mime=${mime,,} mime=${mime//[[:space:]]/} case "$expected_type:$mime" in html:text/html|svg:image/svg+xml|ico:image/x-icon|ico:image/vnd.microsoft.icon|ico:image/ico|ico:application/octet-stream|png:image/png|json:application/json|json:text/json|json:application/manifest+json) ;; *) echo "公网 Content-Type 异常:$path 返回 ${content_type:-}" >&2 return 1 ;; esac if [[ "$expected_type" == "html" ]]; then local html_vpaths=( "/favicon.svg?v=$ICON_VERSION_SVG" "/favicon.ico?v=$ICON_VERSION_ICO" "/favicon-16x16.png?v=$ICON_VERSION_FAV16" "/favicon-32x32.png?v=$ICON_VERSION_FAV32" "/apple-touch-icon.png?v=$ICON_VERSION_APPLE" "/manifest.json?v=$ICON_VERSION_MANIFEST" ) for vpath in "${html_vpaths[@]}"; do if ! grep -Fq "$vpath" "$body_file"; then echo "公网 HTML 响应未包含版本化路径:$vpath ($path)" >&2 return 1 fi done fi if [[ "$path" == /manifest.json* ]]; then if ! grep -Fq "/pwa-192.png?v=$ICON_VERSION_PWA192" "$body_file"; then echo "公网清单响应未包含版本化 PWA 图标路径:/pwa-192.png?v=$ICON_VERSION_PWA192" >&2 return 1 fi if ! grep -Fq "/pwa-512.png?v=$ICON_VERSION_PWA512" "$body_file"; then echo "公网清单响应未包含版本化 PWA 图标路径:/pwa-512.png?v=$ICON_VERSION_PWA512" >&2 return 1 fi fi if [[ -n "$local_file" ]]; then local_hash=$(sha256sum -- "$local_file") local_hash=${local_hash%% *} if [[ "$path" =~ \?v=([0-9a-f]{12})$ ]]; then local expected_v="${BASH_REMATCH[1]}" if [[ "${local_hash:0:12}" != "$expected_v" ]]; then echo "本地文件哈希与路径版本不匹配:$path (文件前12位 ${local_hash:0:12}, 路径 $expected_v)" >&2 return 1 fi fi remote_hash=$(sha256sum -- "$body_file") remote_hash=${remote_hash%% *} if [[ "$local_hash" != "$remote_hash" ]]; then echo "公网文件与本地构建不一致:$path (本地 $local_hash, 远端 $remote_hash)" >&2 return 1 fi fi echo "已验证 $path ($status, $mime)" } public_fail_rollback() { local failed_resource="$1" echo "公网验收失败:$failed_resource" >&2 echo "==> 开始通过远端持锁协调器执行原子回滚..." >&2 local rollback_status="FAIL" if [[ -n "${COORD_IN_FD_OPEN:-}" && -n "${COORD_OUT_FD_OPEN:-}" && -n "${COORD_PID:-}" ]]; then cat <&3 cd '$REMOTE_DIR' # 注:线上当前已有 dist.previous;未来若是首次部署或无上一版历史,走明确的 no rollback 分支并保留当前版本 if [[ ! -e dist.previous && ! -L dist.previous ]]; then echo "无 dist.previous 历史版本(首次部署或历史版本不存在),无法自动回滚,保留当前版本" >&2 echo "ROLLBACK_FAIL" elif [[ -L dist.previous || ! -d dist.previous || ! -s dist.previous/index.html ]]; then echo "历史版本 dist.previous 不合法(符号链接、非有效目录或缺少 index.html),无法安全回滚,保留当前版本" >&2 echo "ROLLBACK_FAIL" elif [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then echo "线上目录 dist 为符号链接或缺少有效 index.html,无法安全进行双向原子交换回滚,保留两份目录" >&2 echo "ROLLBACK_FAIL" else coord_exchange() { python3 -I -c ' import ctypes, os, sys AT_FDCWD = -100 RENAME_EXCHANGE = 2 libc = ctypes.CDLL(None, use_errno=True) try: renameat2 = libc.renameat2 except AttributeError as exc: sys.exit("libc does not provide renameat2") renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] renameat2.restype = ctypes.c_int if renameat2(AT_FDCWD, os.fsencode(sys.argv[1]), AT_FDCWD, os.fsencode(sys.argv[2]), RENAME_EXCHANGE) != 0: err = ctypes.get_errno() sys.exit(f"renameat2 error {err}: {os.strerror(err)}") ' "\$1" "\$2" } coord_origin_healthy() { local resp resp=\$(curl -fsS --connect-timeout 3 --max-time 10 -H 'Host: focus.102434.xyz' http://127.0.0.1/) || return 1 [[ "\$resp" == *'id="root"'* ]] } if ! coord_exchange dist dist.previous; then echo "警告:远端回滚原子交换 renameat2(dist, dist.previous) 失败,保留两份目录" >&2 echo "ROLLBACK_FAIL" elif ! coord_origin_healthy || [[ -L dist || ! -d dist || ! -s dist/index.html ]]; then echo "警告:远端回滚后上一版源站健康检查失败,正在交换回部署新版本" >&2 if coord_exchange dist dist.previous; then echo "已恢复部署新版本 dist,保留两份目录 (dist 与 dist.previous)" >&2 else echo "严重警告:二次交换回新版本失败!两份目录均已保留,请人工核查 dist 与 dist.previous" >&2 fi echo "ROLLBACK_FAIL" else echo "远端源站已成功回滚至上一版本 dist.previous,源站健康检查通过;新部署版本保留在 dist.previous" >&2 echo "ROLLBACK_OK" fi fi ROLLBACK_EOF local line while true; do if ! IFS= read -r -t 30 line <&4; then echo "读取远端回滚输出超时或连接异常中断(30s 无响应)" >&2 rollback_status="TIMEOUT" break fi if [[ "$line" == "ROLLBACK_OK" ]]; then rollback_status="OK" break elif [[ "$line" == "ROLLBACK_FAIL" ]]; then rollback_status="FAIL" break else echo " [remote-rollback] $line" fi done echo "exit 1" >&3 2>/dev/null || true exec 3>&- 2>/dev/null || true COORD_IN_FD_OPEN="" else echo "远端协调器未就绪或连接已关闭,无法执行回滚" >&2 fi if [[ "$rollback_status" == "OK" ]]; then echo "公网验证失败,已成功回滚远端源站至上一版本 (dist.previous)" >&2 else echo "警告:公网验证失败,远端自动回滚未能成功(状态: $rollback_status),已保留现场目录供人工排查" >&2 fi exit 1 } PUBLIC_CHECK_COUNT=0 echo "==> 验证公网响应和图标内容" if ! check_public_resource / html "$ROOT_DIR/dist/index.html"; then public_fail_rollback "首页 /" fi if ! check_public_resource /index.html html "$ROOT_DIR/dist/index.html"; then public_fail_rollback "/index.html" fi if ! check_public_resource "/favicon.svg?v=$ICON_VERSION_SVG" svg "$ROOT_DIR/dist/favicon.svg"; then public_fail_rollback "favicon.svg" fi if ! check_public_resource "/favicon.ico?v=$ICON_VERSION_ICO" ico "$ROOT_DIR/dist/favicon.ico"; then public_fail_rollback "favicon.ico" fi if ! check_public_resource "/favicon-16x16.png?v=$ICON_VERSION_FAV16" png "$ROOT_DIR/dist/favicon-16x16.png"; then public_fail_rollback "favicon-16x16.png" fi if ! check_public_resource "/favicon-32x32.png?v=$ICON_VERSION_FAV32" png "$ROOT_DIR/dist/favicon-32x32.png"; then public_fail_rollback "favicon-32x32.png" fi if ! check_public_resource "/apple-touch-icon.png?v=$ICON_VERSION_APPLE" png "$ROOT_DIR/dist/apple-touch-icon.png"; then public_fail_rollback "apple-touch-icon.png" fi if ! check_public_resource "/manifest.json?v=$ICON_VERSION_MANIFEST" json "$ROOT_DIR/dist/manifest.json"; then public_fail_rollback "manifest.json" fi if ! check_public_resource "/pwa-192.png?v=$ICON_VERSION_PWA192" png "$ROOT_DIR/dist/pwa-192.png"; then public_fail_rollback "pwa-192.png" fi if ! check_public_resource "/pwa-512.png?v=$ICON_VERSION_PWA512" png "$ROOT_DIR/dist/pwa-512.png"; then public_fail_rollback "pwa-512.png" fi SWITCH_COMPLETED=1 # 5. 全部验证通过后,释放远端部署锁并有界等待协调器退出 echo "==> 释放远端部署锁" echo "exit 0" >&3 exec 3>&- COORD_IN_FD_OPEN="" coord_exited=0 for (( i = 0; i < 100; i++ )); do if ! kill -0 "$COORD_PID" 2>/dev/null; then coord_exited=1 break fi sleep 0.1 done if [[ "$coord_exited" -eq 1 ]]; then if ! wait "$COORD_PID"; then echo "远端协调器退出异常" >&2 COORD_PID="" exec 4<&- 2>/dev/null || true COORD_OUT_FD_OPEN="" exit 1 fi COORD_PID="" else echo "远端协调器在 exit 0 后未能在 10s 内正常退出,强制终止" >&2 kill "$COORD_PID" 2>/dev/null || true for (( i = 0; i < 20; i++ )); do kill -0 "$COORD_PID" 2>/dev/null || break sleep 0.1 done kill -9 "$COORD_PID" 2>/dev/null || true wait "$COORD_PID" 2>/dev/null || true COORD_PID="" exec 4<&- 2>/dev/null || true COORD_OUT_FD_OPEN="" exit 1 fi exec 4<&- 2>/dev/null || true COORD_OUT_FD_OPEN="" if [[ "$CF_PURGE_SUCCESS" -eq 1 ]]; then echo "部署完成:https://focus.102434.xyz" else echo "源站部署成功,Cloudflare purge失败,图标通过版本化URL更新;旧缓存记录未主动清除" echo "部署完成:https://focus.102434.xyz" fi