106 lines
5.7 KiB
Bash
106 lines
5.7 KiB
Bash
#!/bin/bash
|
||
# web_hga 生产服 tar.gz 更新入口;需预先配置 Supervisor 程序 web_hga_h5。
|
||
set -Eeuo pipefail
|
||
DIR=$(cd "$(dirname "$0")" && pwd)
|
||
PROGRAM="${DEPLOY_PROGRAM:-web_hga_h5}"
|
||
PACKAGE_NAME="${DEPLOY_PACKAGE_NAME:-web_hga_h5.tar.gz}"
|
||
CHECKSUM_NAME="$PACKAGE_NAME.sha256"
|
||
[[ "$PROGRAM" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]] || { echo 'DEPLOY_PROGRAM 格式无效' >&2; exit 1; }
|
||
[[ "$PACKAGE_NAME" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*\.tar\.gz$ ]] || { echo 'DEPLOY_PACKAGE_NAME 必须是 .tar.gz 文件名' >&2; exit 1; }
|
||
CURRENT="$DIR/next"
|
||
LOCK="$DIR/.$PROGRAM.lock"
|
||
STAGING=''; UPLOAD=''; PREVIOUS=''; FAILED=''
|
||
MOVED_OLD=0; INSTALLED=0; STOPPED=0; WAS_RUNNING=0
|
||
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
||
for tool in node sha256sum tar supervisorctl mktemp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
|
||
node -e 'const [a,b]=process.versions.node.split(".").map(Number);if(a<20||a===20&&b<9)process.exit(1)' || fail '需要 Node.js >=20.9'
|
||
mkdir "$LOCK" 2>/dev/null || fail "已有更新进程或残留锁:$LOCK"
|
||
finish() {
|
||
local result=$?
|
||
trap - EXIT INT TERM
|
||
if [ "$result" != 0 ] && { [ "$STOPPED" = 1 ] || [ "$INSTALLED" = 1 ] || [ "$MOVED_OLD" = 1 ]; }; then
|
||
printf '更新失败,恢复上一版本。\n' >&2
|
||
supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true
|
||
if [ "$INSTALLED" = 1 ]; then
|
||
FAILED="$DIR/next.failed.$(date +%Y%m%d%H%M%S).$$"
|
||
mv "$CURRENT" "$FAILED" || { echo "无法保存失败版本:$CURRENT" >&2; result=1; }
|
||
fi
|
||
if [ "$MOVED_OLD" = 1 ]; then
|
||
mv "$PREVIOUS" "$CURRENT" || { echo "需要手动恢复:$PREVIOUS → $CURRENT" >&2; result=1; }
|
||
fi
|
||
if [ "$WAS_RUNNING" = 1 ]; then supervisorctl start "$PROGRAM" || result=1; fi
|
||
fi
|
||
if [ -n "$STAGING" ] && [ -d "$STAGING" ]; then rm -rf "$STAGING"; fi
|
||
if [ -n "$UPLOAD" ] && [ -d "$UPLOAD" ]; then rm -rf "$UPLOAD"; fi
|
||
rmdir "$LOCK" 2>/dev/null || true
|
||
exit "$result"
|
||
}
|
||
trap finish EXIT
|
||
trap 'exit 130' INT
|
||
trap 'exit 143' TERM
|
||
[ ! -L "$CURRENT" ] && { [ ! -e "$CURRENT" ] || [ -d "$CURRENT" ]; } || fail 'next 必须是普通目录'
|
||
STATUS=$(supervisorctl status "$PROGRAM" 2>/dev/null || true)
|
||
[[ "$STATUS" == "$PROGRAM "* ]] && [[ "$STATUS" != *'ERROR'* ]] || fail "请先配置 Supervisor 程序 $PROGRAM,command 指向 node $CURRENT/start.mjs"
|
||
if [[ "$STATUS" == *'RUNNING'* ]]; then WAS_RUNNING=1; fi
|
||
[ -s "$DIR/$PACKAGE_NAME" ] && [ -s "$DIR/$CHECKSUM_NAME" ] || fail "缺少 $PACKAGE_NAME 或 SHA-256"
|
||
# Freeze the uploaded pair before validation/extraction; concurrent uploads cannot replace this copy.
|
||
UPLOAD=$(mktemp -d "$DIR/.prod-upload.XXXXXX")
|
||
cp "$DIR/$PACKAGE_NAME" "$DIR/$CHECKSUM_NAME" "$UPLOAD/"
|
||
(cd "$UPLOAD" && sha256sum -c "$CHECKSUM_NAME") || fail 'SHA-256 校验失败'
|
||
LC_ALL=C tar -tzf "$UPLOAD/$PACKAGE_NAME" > "$UPLOAD/members" || fail 'tar.gz 压缩包损坏'
|
||
while IFS= read -r entry; do
|
||
entry="${entry#./}"
|
||
case "$entry" in
|
||
/*|../*|*/../*|*/..|*\\*) fail "非法 tar.gz 路径:$entry" ;;
|
||
esac
|
||
done < "$UPLOAD/members"
|
||
# Reject symlinks and special entries before extraction, including hard links to outside paths.
|
||
LC_ALL=C tar -tvzf "$UPLOAD/$PACKAGE_NAME" > "$UPLOAD/types" || fail '无法检查 tar.gz 文件类型'
|
||
while IFS= read -r entry; do
|
||
case "$entry" in
|
||
-?*|d?*) ;;
|
||
*) fail '生产包只允许普通文件和目录,不允许链接或特殊文件' ;;
|
||
esac
|
||
done < "$UPLOAD/types"
|
||
STAGING=$(mktemp -d "$DIR/.prod-release.XXXXXX")
|
||
# mktemp -d 建的目录是 700,换上线后非 root 的 Supervisor 进程进不去会 spawn error;改成常规的 755。
|
||
chmod 755 "$STAGING"
|
||
tar -xzf "$UPLOAD/$PACKAGE_NAME" -C "$STAGING" --no-same-owner --no-same-permissions
|
||
[ -z "$(find "$STAGING" -type l -print -quit)" ] || fail '生产包不允许符号链接'
|
||
# standalone 打包:start.mjs + server.js + .next + public + config/production.env + node_modules/dotenv
|
||
for file in start.mjs server.js .next/BUILD_ID config/production.env package.json; do
|
||
[ -s "$STAGING/$file" ] || fail "包内缺少 $file"
|
||
done
|
||
[ -d "$STAGING/.next/static" ] && [ -d "$STAGING/public" ] || fail '包内缺少静态资源'
|
||
[ -d "$STAGING/node_modules/dotenv" ] || fail '包内缺少 node_modules/dotenv'
|
||
# 保留服务端 production.local.env(若存在且不是软链接)
|
||
if [ -f "$CURRENT/config/production.local.env" ] && [ ! -L "$CURRENT/config/production.local.env" ]; then
|
||
cp "$CURRENT/config/production.local.env" "$STAGING/config/production.local.env"
|
||
chmod 600 "$STAGING/config/production.local.env"
|
||
fi
|
||
# 保留运行期 SEO 缓存(若有)
|
||
if [ -d "$CURRENT/.next/cache/seo" ] && [ ! -L "$CURRENT/.next/cache/seo" ]; then
|
||
mkdir -p "$STAGING/.next/cache"
|
||
cp -R "$CURRENT/.next/cache/seo" "$STAGING/.next/cache/seo"
|
||
fi
|
||
# 读取包内 production.env 的 PORT,用于下面的健康检查
|
||
APP_PORT=$(awk -F= '/^[[:space:]]*PORT[[:space:]]*=/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2);gsub(/^["'\'']|["'\'']$/,"",$2);print $2;exit}' "$STAGING/config/production.env")
|
||
[ -n "$APP_PORT" ] || fail '无法从 config/production.env 读取 PORT'
|
||
# Stop only after validation has succeeded.
|
||
if [ "$WAS_RUNNING" = 1 ]; then supervisorctl stop "$PROGRAM"; STOPPED=1; fi
|
||
if [ -d "$CURRENT" ]; then
|
||
PREVIOUS="$DIR/next.prev.$(date +%Y%m%d%H%M%S).$$"
|
||
mv "$CURRENT" "$PREVIOUS"; MOVED_OLD=1
|
||
fi
|
||
mv "$STAGING" "$CURRENT"; STAGING=''; INSTALLED=1
|
||
supervisorctl start "$PROGRAM"
|
||
# 等启动、再做本机健康探测
|
||
sleep 3
|
||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||
if curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:$APP_PORT/"; then break; fi
|
||
sleep 1
|
||
if [ "$i" = 10 ]; then fail "健康检查失败:http://127.0.0.1:$APP_PORT/"; fi
|
||
done
|
||
supervisorctl status "$PROGRAM"
|
||
printf '生产服更新完成:%s;上一版本:%s\n' "$CURRENT" "${PREVIOUS:-无}"
|