Files
thebet365/scripts/deploy-lib.sh
2026-06-13 22:16:14 +08:00

392 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$DEPLOY_LIB_DIR/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.prod.yml"
ENV_FILE="$ROOT/.env.docker"
ENV_EXAMPLE_FILE="$ROOT/.env.docker.example"
BACKUP_DIR="$ROOT/backups"
DEPLOY_STATE_DIR="$ROOT/.deploy"
RELEASES_DIR="$DEPLOY_STATE_DIR/releases"
CURRENT_RELEASE_FILE="$DEPLOY_STATE_DIR/current-release.env"
PREVIOUS_RELEASE_FILE="$DEPLOY_STATE_DIR/previous-release.env"
DEPLOY_IMAGE_TAG="${DEPLOY_IMAGE_TAG:-}"
LAST_DB_BACKUP=""
LAST_UPLOADS_BACKUP=""
log() {
printf '%s\n' "[$(basename "$0")] $*"
}
warn() {
printf '%s\n' "警告: $*" >&2
}
die() {
printf '%s\n' "错误: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "未找到命令: $1"
}
require_docker() {
require_command docker
docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2请确认可执行 docker compose"
}
validate_image_tag() {
local tag="$1"
printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' ||
die "镜像 tag 不合法: $tag"
}
set_deploy_image_tag() {
local tag="$1"
[ -n "$tag" ] || die "镜像 tag 不能为空"
validate_image_tag "$tag"
DEPLOY_IMAGE_TAG="$tag"
}
infer_image_tag_from_tar() {
local image_tar="$1"
local base tag
base="$(basename "$image_tar")"
tag="${base#thebet365-images-}"
tag="${tag%.tar}"
if [ "$tag" != "$base" ] && [ -n "$tag" ]; then
printf '%s' "$tag"
fi
}
compose() {
local tag
tag="$(current_image_tag)"
IMAGE_TAG="$tag" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
}
ensure_env_file() {
if [ -f "$ENV_FILE" ]; then
return 0
fi
if [ ! -f "$ENV_EXAMPLE_FILE" ]; then
die "未找到 .env.docker 或 .env.docker.example"
fi
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
warn "已从 .env.docker.example 创建 .env.docker"
warn "请先修改 POSTGRES_PASSWORD、JWT_SECRET、短信配置和端口后再重新执行部署脚本"
return 1
}
env_value() {
local key="$1"
local line
line="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" | tail -n 1 || true)"
line="${line#*=}"
line="${line%$'\r'}"
line="${line%\"}"
line="${line#\"}"
line="${line%\'}"
line="${line#\'}"
printf '%s' "$line"
}
set_env_value() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
if grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then
awk -v key="$key" -v value="$value" '
BEGIN { done = 0 }
$0 ~ "^[[:space:]]*" key "=" {
if (!done) {
print key "=" value
done = 1
}
next
}
{ print }
END {
if (!done) print key "=" value
}
' "$ENV_FILE" > "$tmp"
else
cp "$ENV_FILE" "$tmp"
printf '\n%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv "$tmp" "$ENV_FILE"
}
current_image_tag() {
local tag="${DEPLOY_IMAGE_TAG:-}"
if [ -z "$tag" ] && [ -f "$ENV_FILE" ]; then
tag="$(env_value IMAGE_TAG)"
fi
tag="${tag:-latest}"
validate_image_tag "$tag"
printf '%s' "$tag"
}
persist_image_tag() {
local tag
tag="$(current_image_tag)"
set_env_value IMAGE_TAG "$tag"
log "当前发布镜像 tag: $tag"
}
validate_prod_env() {
local allow_defaults="${1:-false}"
local postgres_password jwt_secret seed_database bind_addr
postgres_password="$(env_value POSTGRES_PASSWORD)"
jwt_secret="$(env_value JWT_SECRET)"
seed_database="$(env_value SEED_DATABASE)"
bind_addr="$(env_value BIND_ADDR)"
[ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD"
[ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET"
if [ "$allow_defaults" != "true" ]; then
[ "$postgres_password" != "thebet365" ] || die "POSTGRES_PASSWORD 仍是示例值;如确为测试环境,请加 --allow-default-secrets"
[ "$jwt_secret" != "change-me-in-production-use-long-random-string" ] || die "JWT_SECRET 仍是示例值;如确为测试环境,请加 --allow-default-secrets"
fi
if [ "$seed_database" = "true" ]; then
warn ".env.docker 中 SEED_DATABASE=true 会让 api 每次启动都执行 seed生产建议设为 false首次部署脚本会按需一次性 seed"
fi
if [ "${bind_addr:-127.0.0.1}" = "0.0.0.0" ]; then
warn "BIND_ADDR=0.0.0.0 会让 player/admin 端口直接对外监听;生产建议经宝塔反代访问"
fi
if [ "$(env_value CHUANGLAN_ACCOUNT)" = "your_account" ] || [ "$(env_value CHUANGLAN_PASSWORD)" = "your_password" ]; then
warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置"
fi
}
wait_for_service_health() {
local service="$1"
local timeout="${2:-120}"
local start now container_id status
start="$(date +%s)"
while true; do
container_id="$(compose ps -q "$service" 2>/dev/null || true)"
if [ -n "$container_id" ]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)"
if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then
return 0
fi
fi
now="$(date +%s)"
if [ $((now - start)) -ge "$timeout" ]; then
compose logs --tail=120 "$service" || true
die "$service${timeout}s 内未就绪"
fi
sleep 2
done
}
wait_for_stack_ready() {
wait_for_service_health api 180
wait_for_service_health player 120
wait_for_service_health admin 120
}
start_infra() {
log "启动 PostgreSQL / Redis"
compose up -d postgres redis
wait_for_service_health postgres 120
wait_for_service_health redis 120
}
build_app_images() {
log "构建 api / player / admin 镜像: $(current_image_tag)"
compose build api player admin
}
load_image_tar() {
local image_tar="$1"
[ -f "$image_tar" ] || die "镜像包不存在: $image_tar"
log "加载镜像包: $image_tar"
docker load -i "$image_tar"
}
require_images_for_current_tag() {
local tag
tag="$(current_image_tag)"
docker image inspect "thebet365-api:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-api:$tag"
docker image inspect "thebet365-player:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-player:$tag"
docker image inspect "thebet365-admin:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-admin:$tag"
}
run_prisma_migrations() {
log "执行 Prisma 迁移"
compose run --rm --no-deps --entrypoint sh api -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate'
}
show_prisma_status() {
log "检查 Prisma 迁移状态"
compose exec -T api sh -c 'cd /app/apps/api && npx prisma migrate status'
}
safe_backup_prefix() {
local prefix="${1:-manual}"
prefix="$(printf '%s' "$prefix" | tr -c 'A-Za-z0-9_.-' '-')"
prefix="${prefix:-manual}"
printf '%s' "$prefix"
}
write_checksum() {
local file="$1"
local dir base
dir="$(dirname "$file")"
base="$(basename "$file")"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$dir" && sha256sum "$base" > "$base.sha256")
elif command -v shasum >/dev/null 2>&1; then
(cd "$dir" && shasum -a 256 "$base" > "$base.sha256")
else
warn "未找到 sha256sum 或 shasum跳过校验文件: $file"
return 0
fi
log "校验文件完成: $file.sha256"
}
backup_database() {
local prefix="${1:-manual}"
local stamp backup_file
require_command gzip
mkdir -p "$BACKUP_DIR"
prefix="$(safe_backup_prefix "$prefix")"
stamp="$(date +%Y%m%d-%H%M%S)"
backup_file="$BACKUP_DIR/thebet365-db-${prefix}-${stamp}.sql.gz"
log "备份 PostgreSQL -> $backup_file"
compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$backup_file"
write_checksum "$backup_file"
LAST_DB_BACKUP="$backup_file"
log "数据库备份完成: $backup_file"
}
backup_uploads() {
local prefix="${1:-manual}"
local stamp backup_file
mkdir -p "$BACKUP_DIR"
prefix="$(safe_backup_prefix "$prefix")"
stamp="$(date +%Y%m%d-%H%M%S)"
backup_file="$BACKUP_DIR/thebet365-uploads-${prefix}-${stamp}.tar.gz"
log "备份 uploads 卷 -> $backup_file"
compose exec -T api sh -c 'cd /app && mkdir -p uploads && tar -czf - uploads' > "$backup_file"
write_checksum "$backup_file"
LAST_UPLOADS_BACKUP="$backup_file"
log "uploads 备份完成: $backup_file"
}
prune_old_backups() {
local days
days="$(env_value BACKUP_RETENTION_DAYS)"
[ -n "$days" ] || return 0
case "$days" in
*[!0-9]*)
warn "BACKUP_RETENTION_DAYS 不是数字,跳过自动清理: $days"
return 0
;;
esac
[ "$days" -gt 0 ] || return 0
[ -d "$BACKUP_DIR" ] || return 0
log "清理 ${days} 天前的备份"
find "$BACKUP_DIR" -type f \( -name '*.sql.gz' -o -name '*.tar.gz' -o -name '*.sha256' \) -mtime +"$days" -delete
}
admin_user_count() {
compose exec -T postgres psql -U thebet365 -d thebet365 -tAc "select count(*) from users where username = 'admin';" 2>/dev/null | tr -d '[:space:]'
}
seed_production_if_missing_admin() {
local count
count="$(admin_user_count || true)"
[ -n "$count" ] || die "无法检查 admin 用户,请确认数据库迁移已成功"
if [ "$count" = "0" ]; then
log "未发现 admin 用户,执行一次生产 seed"
compose run --rm --no-deps --entrypoint sh -e SEED_MODE=production -e NODE_ENV=production api -c 'cd /app/apps/api && node dist/infrastructure/database/seed-cli.js'
else
log "已存在 admin 用户,跳过生产 seed"
fi
}
current_release_tag() {
if [ -f "$CURRENT_RELEASE_FILE" ]; then
grep -E '^tag=' "$CURRENT_RELEASE_FILE" | tail -n 1 | cut -d= -f2- || true
fi
}
record_release_state() {
local source="${1:-deploy}"
local tag stamp safe_tag manifest previous_tag
tag="$(current_image_tag)"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
safe_tag="$(printf '%s' "$tag" | tr -c 'A-Za-z0-9_.-' '-')"
manifest="$RELEASES_DIR/${stamp}-${safe_tag}.env"
previous_tag="$(current_release_tag)"
mkdir -p "$RELEASES_DIR"
if [ -f "$CURRENT_RELEASE_FILE" ]; then
cp "$CURRENT_RELEASE_FILE" "$PREVIOUS_RELEASE_FILE"
fi
{
printf 'tag=%s\n' "$tag"
printf 'previous_tag=%s\n' "$previous_tag"
printf 'deployed_at=%s\n' "$stamp"
printf 'source=%s\n' "$source"
printf 'api_image=thebet365-api:%s\n' "$tag"
printf 'player_image=thebet365-player:%s\n' "$tag"
printf 'admin_image=thebet365-admin:%s\n' "$tag"
[ -n "$LAST_DB_BACKUP" ] && printf 'db_backup=%s\n' "$LAST_DB_BACKUP"
[ -n "$LAST_UPLOADS_BACKUP" ] && printf 'uploads_backup=%s\n' "$LAST_UPLOADS_BACKUP"
} > "$manifest"
cp "$manifest" "$CURRENT_RELEASE_FILE"
log "发布状态已记录: $manifest"
}
print_stack_urls() {
local player_port admin_port bind_addr tag
player_port="$(env_value PLAYER_PORT)"
admin_port="$(env_value ADMIN_PORT)"
bind_addr="$(env_value BIND_ADDR)"
tag="$(current_image_tag)"
player_port="${player_port:-8082}"
admin_port="${admin_port:-8081}"
bind_addr="${bind_addr:-127.0.0.1}"
printf '\n'
printf '%s\n' "部署完成:"
printf '%s\n' " 镜像 tag: ${tag}"
printf '%s\n' " 玩家端: http://${bind_addr}:${player_port}"
printf '%s\n' " 管理端: http://${bind_addr}:${admin_port}"
printf '%s\n' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问"
printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps"
printf '%s\n' " 当前发布: .deploy/current-release.env"
}