diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..e8c5eb5 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,991 @@ +#!/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 diff --git a/package.json b/package.json index 8629308..c276441 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "deploy": "bash deploy.sh", "lint": "oxlint", "preview": "vite preview" },