#!/usr/bin/env bash # 测试服专用:dist.zip 为 dist/test 内容(不含 node_modules),与校验文件放在本脚本旁。 # 安装到 next/,保留原 Go 文件;仅管理独立的 hkdj_next_h5 Supervisor 程序。 set -Eeuo pipefail DIR=$(cd "$(dirname "$0")" && pwd) PACKAGE="$DIR/dist.zip" CHECKSUM="$DIR/dist.zip.sha256" CURRENT="$DIR/next" PREVIOUS="$DIR/next.prev" PROGRAM=hkdj_next_h5 SUP_CONF_DIR=${SUP_CONF_DIR:-/etc/supervisor/conf.d} SUP_LOG_DIR=${SUP_LOG_DIR:-/var/log/supervisor} CONF="$SUP_CONF_DIR/$PROGRAM.conf" LOCK="$DIR/.$PROGRAM.lock" STAMP=$(date +%Y%m%d%H%M%S)-$$ STAGING= UPLOAD_COPY= HEALTH_FILE= CONF_BACKUP= NEW_CONF= LOCKED=0 SWITCHED=0 OLD_MOVED=0 INSTALLED=0 HAD_CONF=0 CONF_CHANGED=0 WAS_RUNNING=0 fail() { printf '发布失败:%s\n' "$*" >&2; exit 1; } rollback() { echo '检查失败,恢复上一版应用与 Supervisor 配置。' >&2 supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true if [ "$INSTALLED" = 1 ] && [ -d "$CURRENT" ]; then mv "$CURRENT" "$DIR/.${PROGRAM}.failed.$STAMP" || return 1 fi if [ "$OLD_MOVED" = 1 ]; then mv "$PREVIOUS" "$CURRENT" || return 1; fi if [ "$CONF_CHANGED" = 1 ]; then if [ "$HAD_CONF" = 1 ]; then cp -p "$CONF_BACKUP" "$CONF" || return 1 else rm -f "$CONF" || return 1; fi fi supervisorctl reread >/dev/null && supervisorctl update >/dev/null || return 1 if [ "$WAS_RUNNING" = 1 ]; then supervisorctl restart "$PROGRAM" >/dev/null || return 1 else supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true; fi echo '上一版已恢复;失败版本和上传包已保留。' >&2 } finish() { local rc=$? trap - EXIT if [ "$rc" -ne 0 ]; then if [ "$SWITCHED" = 1 ]; then rollback || { echo "自动回滚未完成,请检查 ${CURRENT}、${PREVIOUS} 和 ${CONF_BACKUP}。" >&2 printf '手动恢复:supervisorctl stop %s; mv %q %q; mv %q %q\n' \ "$PROGRAM" "$CURRENT" "$DIR/.${PROGRAM}.failed.manual.$STAMP" "$PREVIOUS" "$CURRENT" >&2 [ -z "$CONF_BACKUP" ] || printf 'cp -p %q %q\n' "$CONF_BACKUP" "$CONF" >&2 echo "然后 supervisorctl reread && supervisorctl update && supervisorctl start $PROGRAM" >&2 } fi echo "发布未完成,保留 $PACKAGE 便于排查。" >&2 fi [ -z "$STAGING" ] || rm -rf "$STAGING" [ -z "$UPLOAD_COPY" ] || rm -rf "$UPLOAD_COPY" [ -z "$HEALTH_FILE" ] || rm -f "$HEALTH_FILE" [ -z "$NEW_CONF" ] || rm -f "$NEW_CONF" [ "$LOCKED" = 0 ] || rmdir "$LOCK" 2>/dev/null || true exit "$rc" } trap finish EXIT trap 'exit 130' INT trap 'exit 143' TERM for command_name in sha256sum unzip supervisorctl curl mktemp; do command -v "$command_name" >/dev/null || fail "缺少 $command_name" done mkdir "$LOCK" 2>/dev/null || fail "另一次发布正在执行($LOCK);确认无发布进程后才能移除此锁" LOCKED=1 [ -f "$PACKAGE" ] && [ -f "$CHECKSUM" ] || fail '缺少 dist.zip 或 dist.zip.sha256' # SCP 本身不持有发布锁:先冻结上传文件,后续校验与解包始终使用同一份快照。 UPLOAD_COPY=$(mktemp -d "$DIR/.${PROGRAM}.upload.XXXXXX") cp "$PACKAGE" "$UPLOAD_COPY/dist.zip" cp "$CHECKSUM" "$UPLOAD_COPY/dist.zip.sha256" EXPECTED=$(awk 'NR == 1 { print tolower($1) }' "$UPLOAD_COPY/dist.zip.sha256") [[ "$EXPECTED" =~ ^[[:xdigit:]]{64}$ ]] || fail 'SHA-256 校验文件格式无效' ACTUAL=$(sha256sum "$UPLOAD_COPY/dist.zip" | awk '{print $1}') [ "$EXPECTED" = "$ACTUAL" ] || fail 'dist.zip SHA-256 不匹配' node_ok() { "$1" -e 'const [a,b]=process.versions.node.split(".").map(Number); process.exit(a>20 || a===20 && b>=9 ? 0 : 1)' 2>/dev/null } candidates=("${NODE_BIN:-}" /usr/local/bin/node /usr/bin/node /opt/node/bin/node) for candidate in /usr/local/node*/bin/node /opt/node*/bin/node \ /usr/local/lib/nodejs/*/bin/node "${HOME:-/root}"/.nvm/versions/node/v*/bin/node; do [ ! -x "$candidate" ] || candidates+=("$candidate") done candidates+=("$(command -v node || true)") NODE_BIN= for candidate in "${candidates[@]}"; do if [ -x "$candidate" ] && node_ok "$candidate"; then NODE_BIN=$candidate; break; fi done [ -n "$NODE_BIN" ] || fail '需要 Node.js >=20.9,推荐 Node.js 22;可用 NODE_BIN 指定路径' export PATH="$(dirname "$NODE_BIN"):$PATH" NPM_BIN="$(dirname "$NODE_BIN")/npm" [ -x "$NPM_BIN" ] || NPM_BIN=$(command -v npm || true) [ -n "$NPM_BIN" ] && [ -x "$NPM_BIN" ] || fail "选定 Node 缺少 npm:$NODE_BIN" supervisorctl pid >/dev/null || fail 'Supervisor 未运行或无法连接' STATUS=$(supervisorctl status "$PROGRAM" 2>/dev/null || true) [[ "$STATUS" != *RUNNING* ]] || WAS_RUNNING=1 [ ! -L "$CURRENT" ] && [ ! -L "$PREVIOUS" ] || fail 'next/next.prev 不允许是符号链接' echo "使用 Node $("$NODE_BIN" -v),在 Linux 暂存目录安装运行依赖。" STAGING=$(mktemp -d "$DIR/.${PROGRAM}.staging.XXXXXX") # 不接受绝对路径、父目录跳转或 node_modules;运行依赖必须由目标机器安装。 unzip -Z1 "$UPLOAD_COPY/dist.zip" | while IFS= read -r entry; do case "$entry" in /*|../*|*/../*|*/..|node_modules|node_modules/*|*/node_modules/*) echo "包内包含不允许的路径:$entry" >&2; exit 1 ;; esac done || fail 'ZIP 路径检查失败' unzip -q "$UPLOAD_COPY/dist.zip" -d "$STAGING" for required in start.mjs server.js package.json package-lock.json .next-test/BUILD_ID \ config/test.env config/secrets/m3u8.key; do [ -s "$STAGING/$required" ] || fail "包中缺少非空文件 $required" done for required in .next-test/static public; do [ -d "$STAGING/$required" ] || fail "包中缺少目录 $required" done [ ! -L "$STAGING/config" ] && [ ! -L "$STAGING/node_modules" ] || fail '包内配置或依赖目录不能是符号链接' # 服务器的运行配置、密钥和已生成 sitemap 优先,始终不覆盖原 Go 应用目录。 cp "$STAGING/config/secrets/m3u8.key" "$UPLOAD_COPY/packaged-m3u8.key" if [ -f "$CURRENT/config/test.local.env" ]; then cp -p "$CURRENT/config/test.local.env" "$STAGING/config/test.local.env" fi if [ -d "$CURRENT/config/secrets" ]; then cp -a "$CURRENT/config/secrets/." "$STAGING/config/secrets/" fi # 空旧密钥不覆盖包中有效密钥,随后再选择服务器已有的非空密钥。 cp "$UPLOAD_COPY/packaged-m3u8.key" "$STAGING/config/secrets/m3u8.key" for existing_key in "$CURRENT/config/secrets/m3u8.key" "$DIR/config/secrets/m3u8.key" "$DIR/conf/m3u8.key"; do if [ -s "$existing_key" ]; then cp -p "$existing_key" "$STAGING/config/secrets/m3u8.key"; break; fi done (cd "$STAGING" && "$NPM_BIN" ci --omit=dev --include=optional --ignore-scripts --no-audit --no-fund) # BEGIN preserve backend sitemaps (cd "$STAGING" && "$NODE_BIN" --input-type=module - "$CURRENT" "$STAGING" "$DIR" <<'NODE' import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { parse } from 'dotenv'; const [current, staging, original] = process.argv.slice(2).map(value => resolve(value)); const read = file => existsSync(file) ? parse(readFileSync(file)) : {}; const config = { ...read(join(staging, 'config/test.env')), ...read(join(staging, 'config/test.local.env')), ...process.env }; const configured = (config.SITEMAP_DIR || './template/pages/sitemaps').trim() || './template/pages/sitemaps'; const source = resolve(current, configured), suffix = relative(current, source); const insideCurrent = suffix !== '..' && !suffix.startsWith('..' + '/') && !isAbsolute(suffix); const nameAllowed = name => name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name); const info = file => { try { return lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }; const withinCurrent = target => { const part = relative(current, target); return part !== '..' && !part.startsWith('../') && !isAbsolute(part); }; const ensureParent = destination => { if (destination === staging) return; const suffix = relative(staging, dirname(destination)); if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('Sitemap destination escapes staging'); let cursor = staging; for (const segment of suffix.split('/').filter(Boolean)) { cursor = join(cursor, segment); if (info(cursor)?.isSymbolicLink()) throw new Error('Sitemap destination parent cannot be a symbolic link'); mkdirSync(cursor, { recursive: true }); } }; const available = directory => { const value = info(directory); // An explicitly empty current directory is authoritative; do not revive older XML. return value?.isSymbolicLink() || value?.isDirectory(); }; const visited = new Set(); const preserveDirectory = (existing, destination) => { if (visited.has(existing)) throw new Error('Sitemap mount contains a symbolic-link cycle'); visited.add(existing); ensureParent(destination); if (info(existing)?.isSymbolicLink()) { const link = readlinkSync(existing), target = resolve(dirname(existing), link); // Keep release-local link targets as well as their link; external writers stay external. if (withinCurrent(target) && available(target)) preserveDirectory(target, resolve(staging, relative(current, target))); rmSync(destination, { recursive: true, force: true }); symlinkSync(link, destination); return; } if (info(destination)?.isSymbolicLink()) throw new Error('Packaged sitemap destination cannot be a symbolic link'); mkdirSync(destination, { recursive: true }); // Server files, including intentional absence, take precedence over stale build copies. for (const name of readdirSync(destination).filter(nameAllowed)) { const target = join(destination, name); if (info(target)?.isFile() || info(target)?.isSymbolicLink()) rmSync(target); } for (const name of readdirSync(existing).filter(nameAllowed)) { const file = join(existing, name); if (!statSync(file, { throwIfNoEntry: false })?.isFile()) continue; cpSync(file, join(destination, name), { dereference: true, preserveTimestamps: true }); } }; // External mounts remain untouched. Absolute paths inside next/ rebase into the new release. if (insideCurrent) { const destination = resolve(staging, suffix); const candidates = [source, join(current, 'template/pages/sitemaps'), join(current, 'data/sitemaps'), join(original, 'template/pages/sitemaps')]; const existing = candidates.find(available); if (existing && existing !== destination) preserveDirectory(existing, destination); } // The original router's second index candidate is independent of SITEMAP_DIR. const fallback = [join(current, 'template/pages/sitemap.xml'), join(original, 'template/pages/sitemap.xml')].find(file => existsSync(file) && statSync(file).isFile()); if (fallback) { const target = join(staging, 'template/pages/sitemap.xml'); ensureParent(target); if (info(target)?.isSymbolicLink()) rmSync(target); cpSync(fallback, target, { dereference: true, preserveTimestamps: true }); } // Preserve the same optional robots overrides that the original Go helper reads. for (const file of ['template/pages/robots.txt', 'templates/pages/robots.txt', 'app/template/pages/robots.txt', 'app/templates/pages/robots.txt']) { const existing = [join(current, file), join(original, file)].find(source => existsSync(source) && statSync(source).isFile()); if (!existing) continue; const target = join(staging, file); ensureParent(target); if (info(target)?.isSymbolicLink()) rmSync(target); cpSync(existing, target, { dereference: true, preserveTimestamps: true }); } NODE ) # END preserve backend sitemaps APP_PORT=$(cd "$STAGING" && "$NODE_BIN" --input-type=module <<'NODE' import { existsSync, readFileSync } from 'node:fs'; import { parse } from 'dotenv'; const read = path => existsSync(path) ? parse(readFileSync(path)) : {}; const config = { ...read('config/test.env'), ...read('config/test.local.env'), ...process.env }; const port = config.PORT || '30334'; if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('Invalid PORT'); if (!/^https?:\/\//.test(config.UPSTREAM_API_URL?.trim() || '')) throw new Error('UPSTREAM_API_URL is required'); console.log(Number(port)); NODE ) mkdir -p "$SUP_CONF_DIR" "$SUP_LOG_DIR" NEW_CONF=$(mktemp "$DIR/.${PROGRAM}.supervisor.XXXXXX") # Supervisor 会解析百分号;路径中的引号与反斜杠同时按其 command 语法转义。 supervisor_quote() { local value=${1//\\/\\\\}; value=${value//\"/\\\"}; printf '%s' "${value//%/%%}"; } cat > "$NEW_CONF" </dev/null || true) if [ "$CODE" = 200 ] && "$NODE_BIN" -e ' try { const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(value.status==="ok" && value.environment==="test" ? 0 : 1); } catch { process.exit(1); }' "$HEALTH_FILE"; then HEALTH_OK=1; break; fi sleep 1 done [ "$HEALTH_OK" = 1 ] || fail '/healthz 未返回测试环境的 HTTP 200 健康响应' CODE=$(curl -sS --connect-timeout 2 --max-time 45 -o /dev/null -w '%{http_code}' "$BASE/" || true) [ "$CODE" = 200 ] || fail "首页检查失败,HTTP $CODE" CODE=$(curl -sS --connect-timeout 2 --max-time 25 -o "$HEALTH_FILE" -w '%{http_code}' "$BASE/enkey" || true) [ "$CODE" = 200 ] && [ -s "$HEALTH_FILE" ] || fail "/enkey 检查失败,HTTP $CODE 或密钥为空" supervisorctl status "$PROGRAM" SWITCHED=0 # 新上传若已覆盖固定文件名,留给下一次发布;清理失败也不改变已通过检查的结果。 LATEST=$(sha256sum "$PACKAGE" 2>/dev/null | awk '{print $1}' || true) if [ "$LATEST" = "$ACTUAL" ]; then rm -f "$PACKAGE" "$CHECKSUM" || echo '提示:服务已更新,但上传包清理失败。' >&2 else echo '提示:上传包已变更或不存在,保留当前文件供下次发布。'; fi echo "测试更新完成:${CURRENT};/healthz、首页、/enkey 检查通过。" echo "请确认 NGINX 将页面及 /enkey、/newenkey、/h5/m3u8/ 代理到 127.0.0.1:${APP_PORT};脚本未修改 NGINX。"