Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
umask 027
|
||||
|
||||
PROJECT_DIR="${PROJECT_DIR:-/www/wwwroot/Hulumath-Web}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-/www/server/pyporject_evn/versions/3.12.13/bin/python3}"
|
||||
VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv-production}"
|
||||
ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env.production}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-hulumath-web}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/www/backup/hulumath}"
|
||||
MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-}"
|
||||
PREVIOUS_REVISION="${PREVIOUS_REVISION:-}"
|
||||
HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}"
|
||||
ROLLBACK_ENABLED=0
|
||||
MYSQL_CREDENTIALS_FILE=""
|
||||
|
||||
log() {
|
||||
printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '\n[ERROR] %s\n' "$*" >&2
|
||||
if [ "$ROLLBACK_ENABLED" -eq 1 ]; then
|
||||
rollback_application 1
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
elif sudo -n true 2>/dev/null; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
fail "部署用户必须是 root,或具备免密 sudo 权限"
|
||||
fi
|
||||
DEPLOY_OWNER="$(id -un):$(id -gn)"
|
||||
|
||||
rollback_application() {
|
||||
local exit_code="${1:-$?}"
|
||||
trap - ERR
|
||||
if [ -n "$MYSQL_CREDENTIALS_FILE" ]; then
|
||||
rm -f "$MYSQL_CREDENTIALS_FILE"
|
||||
fi
|
||||
log "部署失败,开始应用代码回退"
|
||||
|
||||
if [ -z "$PREVIOUS_REVISION" ]; then
|
||||
log "未提供 PREVIOUS_REVISION,跳过自动回退"
|
||||
exit "$exit_code"
|
||||
fi
|
||||
if ! git cat-file -e "${PREVIOUS_REVISION}^{commit}" 2>/dev/null; then
|
||||
log "上一版本 $PREVIOUS_REVISION 不存在,跳过自动回退"
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
git reset --hard "$PREVIOUS_REVISION"
|
||||
if [ ! -f "$PROJECT_DIR/backend/manage.py" ]; then
|
||||
log "上一版本不是 Django 版本,无法自动恢复;数据库备份仍保留在 $BACKUP_DIR"
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
$SUDO chown -R "$DEPLOY_OWNER" "$VENV_DIR"
|
||||
if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
|
||||
$SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles"
|
||||
fi
|
||||
"$VENV_DIR/bin/pip" install --disable-pip-version-check -r "$PROJECT_DIR/requirements.txt"
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
cd "$PROJECT_DIR/backend"
|
||||
"$VENV_DIR/bin/python" manage.py collectstatic --noinput
|
||||
)
|
||||
$SUDO chown -R www:www "$PROJECT_DIR/backend/staticfiles"
|
||||
$SUDO chown -R www:www "$VENV_DIR"
|
||||
$SUDO systemctl restart "$SERVICE_NAME"
|
||||
log "应用代码已回退到 $PREVIOUS_REVISION;数据库未执行反向迁移"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap rollback_application ERR
|
||||
ROLLBACK_ENABLED=1
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
[ -d .git ] || fail "$PROJECT_DIR 不是 Git 仓库"
|
||||
[ -f requirements.txt ] || fail "缺少 requirements.txt"
|
||||
[ -f backend/manage.py ] || fail "缺少 backend/manage.py"
|
||||
[ -f "$ENV_FILE" ] || fail "缺少 $ENV_FILE,请先按 .env.production.example 配置"
|
||||
if [ ! -x "$PYTHON_BIN" ] && [ -x "${PYTHON_BIN%3}" ]; then
|
||||
PYTHON_BIN="${PYTHON_BIN%3}"
|
||||
fi
|
||||
[ -x "$PYTHON_BIN" ] || fail "Python 不存在或不可执行: $PYTHON_BIN"
|
||||
|
||||
set -a
|
||||
# .env.production 必须使用 KEY=value 格式;包含特殊字符的值请用单引号包裹。
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
if [ -z "$HEALTH_HOST" ]; then
|
||||
HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}"
|
||||
fi
|
||||
|
||||
[ "${DJANGO_DEBUG:-}" = "false" ] || fail "生产环境必须设置 DJANGO_DEBUG=false"
|
||||
[[ "${DATABASE_URL:-}" == mysql://* ]] \
|
||||
|| fail "DATABASE_URL 必须是 MySQL 地址"
|
||||
[[ "${REDIS_URL:-}" == redis://* || "${REDIS_URL:-}" == rediss://* ]] \
|
||||
|| fail "REDIS_URL 必须是 Redis 地址"
|
||||
[ -n "${DJANGO_SECRET_KEY:-}" ] || fail "缺少 DJANGO_SECRET_KEY"
|
||||
[ -n "${DJANGO_ALLOWED_HOSTS:-}" ] || fail "缺少 DJANGO_ALLOWED_HOSTS"
|
||||
|
||||
if [ -z "$HEALTH_HOST" ]; then
|
||||
HEALTH_HOST="${DJANGO_ALLOWED_HOSTS%%,*}"
|
||||
fi
|
||||
|
||||
log "创建或更新生产虚拟环境"
|
||||
if [ ! -x "$VENV_DIR/bin/python" ]; then
|
||||
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||
fi
|
||||
$SUDO chown -R "$DEPLOY_OWNER" "$VENV_DIR"
|
||||
"$VENV_DIR/bin/pip" install --disable-pip-version-check --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install --disable-pip-version-check -r requirements.txt
|
||||
|
||||
log "备份 MySQL"
|
||||
if [ -z "$MYSQLDUMP_BIN" ]; then
|
||||
MYSQLDUMP_BIN="$(command -v mysqldump || true)"
|
||||
fi
|
||||
if [ -z "$MYSQLDUMP_BIN" ]; then
|
||||
for candidate in \
|
||||
/www/server/mysql/bin/mysqldump \
|
||||
/usr/bin/mysqldump; do
|
||||
if [ -x "$candidate" ]; then
|
||||
MYSQLDUMP_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[ -x "$MYSQLDUMP_BIN" ] || fail "未找到 mysqldump;可通过 MYSQLDUMP_BIN 指定客户端路径"
|
||||
$SUDO mkdir -p "$BACKUP_DIR"
|
||||
$SUDO chown "$(id -un):$(id -gn)" "$BACKUP_DIR"
|
||||
BACKUP_FILE="$BACKUP_DIR/hulumath_$(date '+%Y%m%d_%H%M%S').sql.gz"
|
||||
MYSQL_CREDENTIALS_FILE="$(mktemp)"
|
||||
chmod 600 "$MYSQL_CREDENTIALS_FILE"
|
||||
MYSQL_DATABASE_NAME="$("$VENV_DIR/bin/python" - "$MYSQL_CREDENTIALS_FILE" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
parsed = urlparse(os.environ["DATABASE_URL"])
|
||||
database = unquote(parsed.path.lstrip("/"))
|
||||
values = {
|
||||
"host": parsed.hostname or "127.0.0.1",
|
||||
"port": str(parsed.port or 3306),
|
||||
"user": unquote(parsed.username or ""),
|
||||
"password": unquote(parsed.password or ""),
|
||||
"database": database,
|
||||
}
|
||||
if not values["user"] or not database:
|
||||
raise SystemExit("DATABASE_URL 缺少用户名或数据库名")
|
||||
if any("\n" in value or "\r" in value for value in values.values()):
|
||||
raise SystemExit("DATABASE_URL 包含非法换行")
|
||||
|
||||
def quote(value):
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as target:
|
||||
target.write("[client]\n")
|
||||
target.write(f"host={quote(values['host'])}\n")
|
||||
target.write(f"port={values['port']}\n")
|
||||
target.write(f"user={quote(values['user'])}\n")
|
||||
target.write(f"password={quote(values['password'])}\n")
|
||||
target.write("default-character-set=utf8mb4\n")
|
||||
print(database)
|
||||
PY
|
||||
)"
|
||||
"$MYSQLDUMP_BIN" \
|
||||
--defaults-extra-file="$MYSQL_CREDENTIALS_FILE" \
|
||||
--single-transaction \
|
||||
--quick \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--hex-blob \
|
||||
--set-gtid-purged=OFF \
|
||||
"$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE"
|
||||
rm -f "$MYSQL_CREDENTIALS_FILE"
|
||||
MYSQL_CREDENTIALS_FILE=""
|
||||
test -s "$BACKUP_FILE" || fail "数据库备份为空: $BACKUP_FILE"
|
||||
find "$BACKUP_DIR" -type f -name 'hulumath_*.sql.gz' -mtime +14 -delete
|
||||
log "数据库备份完成: $BACKUP_FILE"
|
||||
|
||||
log "执行 Django 发布检查、迁移和静态资源收集"
|
||||
if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
|
||||
$SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles"
|
||||
fi
|
||||
cd "$PROJECT_DIR/backend"
|
||||
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level WARNING --database default
|
||||
"$VENV_DIR/bin/python" manage.py check_mysql
|
||||
"$VENV_DIR/bin/python" manage.py migrate --noinput
|
||||
"$VENV_DIR/bin/python" manage.py collectstatic --noinput
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
log "安装或更新 systemd 服务"
|
||||
$SUDO install -m 0644 deploy/hulumath-web.service /etc/systemd/system/hulumath-web.service
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable "$SERVICE_NAME"
|
||||
|
||||
if ! $SUDO systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
if command -v ss >/dev/null 2>&1 && ss -ltn | grep -q ':8000 '; then
|
||||
fail "8000 端口仍被旧 Flask/Gunicorn 占用。首次切换需先停止宝塔中的旧 Flask 进程"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "修正运行目录权限并重启 ASGI 服务"
|
||||
$SUDO chown -R www:www "$PROJECT_DIR/backend/staticfiles"
|
||||
$SUDO chown -R www:www "$VENV_DIR"
|
||||
$SUDO systemctl restart "$SERVICE_NAME"
|
||||
$SUDO systemctl is-active --quiet "$SERVICE_NAME"
|
||||
|
||||
log "检查 HTTP 健康状态"
|
||||
HTTP_OK=0
|
||||
for _ in $(seq 1 20); do
|
||||
if curl --fail --silent --show-error \
|
||||
--header "Host: $HEALTH_HOST" \
|
||||
--header "X-Forwarded-Proto: https" \
|
||||
http://127.0.0.1:8000/health/ | grep -q '"status": "ok"'; then
|
||||
HTTP_OK=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$HTTP_OK" -eq 1 ] || fail "HTTP 健康检查失败"
|
||||
|
||||
log "检查 WebSocket 与 Redis Channel Layer"
|
||||
"$VENV_DIR/bin/python" - <<'PY'
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
|
||||
async def check():
|
||||
async with connect("ws://127.0.0.1:8000/ws/health/", open_timeout=5) as websocket:
|
||||
payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=5))
|
||||
if payload != {"status": "ok", "channel_layer": "ok"}:
|
||||
raise RuntimeError(f"unexpected WebSocket health payload: {payload}")
|
||||
|
||||
|
||||
asyncio.run(check())
|
||||
PY
|
||||
|
||||
trap - ERR
|
||||
ROLLBACK_ENABLED=0
|
||||
log "部署完成,当前版本: $(git rev-parse --short HEAD)"
|
||||
log "备份文件: $BACKUP_FILE"
|
||||
Reference in New Issue
Block a user