311 lines
10 KiB
Bash
Executable File
311 lines
10 KiB
Bash
Executable File
#!/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:-}"
|
||
MYSQL_CONTAINER="${MYSQL_CONTAINER:-}"
|
||
PREVIOUS_REVISION="${PREVIOUS_REVISION:-}"
|
||
HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}"
|
||
SMOKE_BASE_URL="${DEPLOY_SMOKE_BASE_URL:-}"
|
||
SMOKE_WS_URL="${DEPLOY_SMOKE_WS_URL:-}"
|
||
ROLLBACK_ENABLED=0
|
||
MYSQL_CREDENTIALS_FILE=""
|
||
MYSQL_CONTAINER_CREDENTIALS_PATH=""
|
||
|
||
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_CONTAINER_CREDENTIALS_PATH" ] && [ -n "$MYSQL_CONTAINER" ]; then
|
||
docker exec "$MYSQL_CONTAINER" rm -f "$MYSQL_CONTAINER_CREDENTIALS_PATH" \
|
||
>/dev/null 2>&1 || true
|
||
fi
|
||
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 [ -n "$MYSQL_CONTAINER" ]; then
|
||
command -v docker >/dev/null 2>&1 || fail "配置了 MYSQL_CONTAINER,但服务器没有 docker 命令"
|
||
docker inspect "$MYSQL_CONTAINER" >/dev/null 2>&1 \
|
||
|| fail "MySQL 容器不存在: $MYSQL_CONTAINER"
|
||
docker exec "$MYSQL_CONTAINER" mysqldump --version \
|
||
| grep -Eq '(Ver|Distrib)[[:space:]]+8\.' \
|
||
|| fail "容器内 mysqldump 不是 MySQL 8: $MYSQL_CONTAINER"
|
||
else
|
||
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;Docker MySQL 请设置 MYSQL_CONTAINER"
|
||
"$MYSQLDUMP_BIN" --version | grep -Eq '(Ver|Distrib)[[:space:]]+8\.' \
|
||
|| fail "宿主机 mysqldump 不是 MySQL 8;请设置 MYSQL_CONTAINER 使用容器内客户端"
|
||
fi
|
||
$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")
|
||
if not os.environ.get("MYSQL_CONTAINER"):
|
||
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
|
||
)"
|
||
if [ -n "$MYSQL_CONTAINER" ]; then
|
||
MYSQL_CONTAINER_CREDENTIALS_PATH="/tmp/hulumath-dump-$$.cnf"
|
||
docker cp "$MYSQL_CREDENTIALS_FILE" \
|
||
"$MYSQL_CONTAINER:$MYSQL_CONTAINER_CREDENTIALS_PATH"
|
||
docker exec "$MYSQL_CONTAINER" mysqldump \
|
||
--defaults-extra-file="$MYSQL_CONTAINER_CREDENTIALS_PATH" \
|
||
--single-transaction \
|
||
--quick \
|
||
--triggers \
|
||
--hex-blob \
|
||
--no-tablespaces \
|
||
--set-gtid-purged=OFF \
|
||
"$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE"
|
||
docker exec "$MYSQL_CONTAINER" rm -f "$MYSQL_CONTAINER_CREDENTIALS_PATH"
|
||
MYSQL_CONTAINER_CREDENTIALS_PATH=""
|
||
else
|
||
"$MYSQLDUMP_BIN" \
|
||
--defaults-extra-file="$MYSQL_CREDENTIALS_FILE" \
|
||
--single-transaction \
|
||
--quick \
|
||
--triggers \
|
||
--hex-blob \
|
||
--no-tablespaces \
|
||
--set-gtid-purged=OFF \
|
||
"$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE"
|
||
fi
|
||
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"
|
||
if [ "${DJANGO_USE_HTTPS:-true}" = "true" ]; then
|
||
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level WARNING --database default
|
||
else
|
||
log "临时 HTTP 模式:部署安全检查只阻断 ERROR;绑定域名后应启用 HTTPS"
|
||
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level ERROR --database default
|
||
fi
|
||
"$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
|
||
|
||
if [ -n "$SMOKE_BASE_URL" ] || [ -n "$SMOKE_WS_URL" ]; then
|
||
[ -n "$SMOKE_BASE_URL" ] && [ -n "$SMOKE_WS_URL" ] \
|
||
|| fail "DEPLOY_SMOKE_BASE_URL 和 DEPLOY_SMOKE_WS_URL 必须同时设置"
|
||
[ -f "$PROJECT_DIR/scripts/smoke_production.py" ] \
|
||
|| fail "缺少生产冒烟脚本: scripts/smoke_production.py"
|
||
log "检查 Nginx、业务页面与公网 WebSocket"
|
||
"$VENV_DIR/bin/python" "$PROJECT_DIR/scripts/smoke_production.py" \
|
||
--base-url "$SMOKE_BASE_URL" \
|
||
--ws-url "$SMOKE_WS_URL"
|
||
fi
|
||
|
||
trap - ERR
|
||
ROLLBACK_ENABLED=0
|
||
log "部署完成,当前版本: $(git rev-parse --short HEAD)"
|
||
log "备份文件: $BACKUP_FILE"
|