Files
focus/deploy.sh

992 lines
34 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
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 <<HANDSHAKE_EOF >&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 <<SW_CMD >&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" <<CF_CFG_EOF
header = "Authorization: Bearer $API_TOKEN"
header = "Content-Type: application/json"
CF_CFG_EOF
PURGE_CURL_FAILED=0
PURGE_HTTP_STATUS=""
if ! PURGE_HTTP_STATUS=$(curl --silent --show-error --retry 2 --retry-all-errors \
--connect-timeout 10 --max-time 60 \
--config "$CF_CURL_CONFIG" \
-X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-o "$TMP_DIR/purge-response.json" -w '%{http_code}' \
--data-binary @- <<'PURGE_JSON'
{"files":[
"https://focus.102434.xyz/",
"https://focus.102434.xyz/index.html",
"https://focus.102434.xyz/manifest.json",
"https://focus.102434.xyz/favicon.svg",
"https://focus.102434.xyz/favicon.ico",
"https://focus.102434.xyz/favicon-16x16.png",
"https://focus.102434.xyz/favicon-32x32.png",
"https://focus.102434.xyz/apple-touch-icon.png",
"https://focus.102434.xyz/pwa-192.png",
"https://focus.102434.xyz/pwa-512.png"
]}
PURGE_JSON
); then
PURGE_CURL_FAILED=1
fi
CF_PURGE_SUCCESS=0
PURGE_PARSE_RESULT="$TMP_DIR/purge-result.txt"
python3 -I - "$TMP_DIR/purge-response.json" "${PURGE_HTTP_STATUS:-000}" "$PURGE_CURL_FAILED" "$PURGE_PARSE_RESULT" <<'PY'
import json
import sys
resp_file = sys.argv[1]
status_raw = sys.argv[2]
curl_failed = sys.argv[3] == "1"
out_file = sys.argv[4]
try:
status = int(status_raw)
except ValueError:
status = 0
if curl_failed:
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\tcurl 请求失败 (HTTP {status})")
sys.exit(0)
raw_body = ""
try:
with open(resp_file, "r", encoding="utf-8", errors="replace") as f:
raw_body = f.read()
except OSError as exc:
with open(out_file, "w", encoding="utf-8") as out:
out.write(f"FAIL\t无法读取 Cloudflare 响应文件 (HTTP {status}): {exc}")
sys.exit(0)
try:
response = json.loads(raw_body)
except json.JSONDecodeError:
snippet = raw_body.strip().splitlines()[0][:120] if raw_body.strip() else "<空响应>"
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:-<empty>}" >&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 <<ROLLBACK_EOF >&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