1091 lines
42 KiB
Bash
1091 lines
42 KiB
Bash
#!/usr/bin/env bash
|
||
# -E 让 ERR trap 在函数内部同样生效,保证不可逆区间的异常一定触发回滚
|
||
set -Eeuo pipefail
|
||
|
||
# ============================================
|
||
# Gitea 安全升级脚本 (MySQL 版)
|
||
#
|
||
# 设计原则:
|
||
# 1. 先拉镜像再动服务 —— 网络失败时服务零影响
|
||
# 2. 只停 Gitea,MySQL 保持运行 —— 数据库容器全程不重建
|
||
# 3. 冷备份(Gitea 已停)+ 备份包完整性校验 —— 快照一致且可用
|
||
# 4. 升级前后比对表行数与迁移版本 —— 数据异常自动回滚
|
||
# 5. 回滚 = 恢复数据库 + 恢复配置 + 切回旧镜像
|
||
# Gitea 跨小版本升级会改数据库结构,旧版本无法直接读新库,
|
||
# 所以「只切镜像」的回滚是无效的,必须回灌 SQL 备份。
|
||
# 6. 全程不删仓库 —— 数据目录用合并解压覆盖,绝不 rm / mv
|
||
#
|
||
# 用法:
|
||
# bash upgrade.sh 升级到 GitHub 最新 release
|
||
# bash upgrade.sh --check 只检查,不做任何改动
|
||
# bash upgrade.sh --version 1.27.1
|
||
# bash upgrade.sh --rollback 回滚到上次升级前的状态
|
||
# bash upgrade.sh --yes 跳过交互确认(用于自动化)
|
||
# ============================================
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
cd "$SCRIPT_DIR"
|
||
|
||
# ===== 输出样式 =====
|
||
if [ -t 1 ]; then
|
||
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
|
||
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
|
||
else
|
||
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
|
||
fi
|
||
|
||
log() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||
info() { echo -e "${CYAN}[i]${NC} $*"; }
|
||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||
error(){ echo -e "${RED}[✗]${NC} $*" >&2; }
|
||
step() { echo ""; echo -e "${BOLD}${CYAN}━━━ $* ━━━${NC}"; }
|
||
|
||
# ===== 全局状态 =====
|
||
GITHUB_REPO="go-gitea/gitea"
|
||
IMAGE_REPO="gitea/gitea"
|
||
DB_NAME="gitea"
|
||
SVC_APP="server" # docker-compose.yml 里的 Gitea 服务名
|
||
SVC_DB="db" # docker-compose.yml 里的 MySQL 服务名
|
||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||
STATE_FILE="$SCRIPT_DIR/.upgrade-state"
|
||
|
||
MODE="upgrade" # upgrade | check | rollback
|
||
TARGET_VERSION=""
|
||
ASSUME_YES=0
|
||
FULL_BACKUP=0
|
||
RUN_DOCTOR=0
|
||
READY_TIMEOUT=300 # 大库迁移可能耗时数分钟
|
||
|
||
SAFETY_STAGE="none" # none → stopped → armed → done
|
||
DB_BACKUP=""
|
||
DATA_BACKUP=""
|
||
META_BACKUP=""
|
||
ROLLBACK_TAG=""
|
||
OLD_IMAGE_REF=""
|
||
CURRENT_VERSION=""
|
||
COUNTS_BEFORE=""
|
||
MIGRATION_BEFORE=""
|
||
|
||
# 用于比对的关键表。故意不含 action(活动流,行数极大且丢了不致命),
|
||
# COUNT(*) 在 InnoDB 上是索引全扫,表选择要克制。
|
||
FINGERPRINT_TABLES="user repository issue comment pull_request release
|
||
attachment lfs_meta_object package access_token public_key
|
||
gpg_key email_address org_user team webhook branch repo_unit"
|
||
|
||
# =============================================================
|
||
# 参数解析
|
||
# =============================================================
|
||
usage() {
|
||
cat <<'EOF'
|
||
Gitea 安全升级脚本
|
||
|
||
用法: bash upgrade.sh [选项]
|
||
|
||
选项:
|
||
-c, --check 只做预检和版本对比,不做任何改动
|
||
-v, --version <ver> 指定目标版本(如 1.27.1),默认取 GitHub 最新 release
|
||
-r, --rollback 回滚到上次升级前的状态(读取 .upgrade-state)
|
||
-y, --yes 跳过交互确认
|
||
-f, --full-backup 数据目录全量打包(含仓库/LFS/包,可能非常大)
|
||
默认只打包配置、密钥、索引等会被升级影响的部分,
|
||
仓库和 LFS 是内容寻址的追加式存储,升级不会改写
|
||
-t, --timeout <秒> 等待 Gitea 就绪的超时,默认 300(迁移大库需要时间)
|
||
--doctor 升级后运行 gitea doctor 一致性检查(仅提示,不触发回滚)
|
||
-h, --help 显示本帮助
|
||
|
||
示例:
|
||
bash upgrade.sh --check # 先看看会发生什么
|
||
bash upgrade.sh # 正式升级
|
||
bash upgrade.sh --version 1.26.4 # 升级到指定版本
|
||
bash upgrade.sh --full-backup # 连仓库一起备份(小实例推荐)
|
||
bash upgrade.sh --rollback # 升级后发现问题,回滚
|
||
EOF
|
||
}
|
||
|
||
parse_args() {
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
-c|--check) MODE="check" ;;
|
||
-r|--rollback) MODE="rollback" ;;
|
||
-y|--yes) ASSUME_YES=1 ;;
|
||
-f|--full-backup) FULL_BACKUP=1 ;;
|
||
--doctor) RUN_DOCTOR=1 ;;
|
||
-v|--version)
|
||
shift
|
||
[ $# -gt 0 ] || { error "--version 需要一个参数"; exit 1; }
|
||
TARGET_VERSION="${1#v}"
|
||
;;
|
||
-t|--timeout)
|
||
shift
|
||
[ $# -gt 0 ] || { error "--timeout 需要一个参数"; exit 1; }
|
||
[[ "$1" =~ ^[0-9]+$ ]] || { error "--timeout 必须是整数秒"; exit 1; }
|
||
READY_TIMEOUT="$1"
|
||
;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) error "未知参数: $1"; echo ""; usage; exit 1 ;;
|
||
esac
|
||
shift
|
||
done
|
||
}
|
||
|
||
confirm() {
|
||
[ "$ASSUME_YES" -eq 1 ] && return 0
|
||
local prompt="$1" answer
|
||
echo ""
|
||
read -r -p "${prompt} [y/N] " answer
|
||
[[ "$answer" =~ ^[Yy]$ ]]
|
||
}
|
||
|
||
# =============================================================
|
||
# 版本号比较
|
||
# =============================================================
|
||
# $1 < $2 返回 0
|
||
ver_lt() {
|
||
[ "$1" = "$2" ] && return 1
|
||
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
|
||
}
|
||
|
||
# 取 major.minor,Gitea 只在 major.minor 相同时保证数据库结构一致
|
||
ver_series() { echo "$1" | cut -d. -f1,2; }
|
||
|
||
# =============================================================
|
||
# 环境加载与预检
|
||
# =============================================================
|
||
load_env() {
|
||
[ -f .env ] || { error ".env 不存在,请先运行 deploy.sh"; exit 1; }
|
||
sed -i 's/\r$//' .env
|
||
set -a; source .env; set +a
|
||
|
||
DATA_DIR="${GITEA_DATA_DIR:-/var/lib/gitea}"
|
||
BACKUP_BASE="${BACKUP_DIR:-/var/backups/gitea}"
|
||
DOMAIN="${GITEA_DOMAIN:-}"
|
||
CURRENT_IMAGE="${GITEA_IMAGE:-gitea/gitea:1.25}"
|
||
|
||
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
|
||
error ".env 中缺少 DB_ROOT_PASSWORD,无法备份数据库"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# 统一的 MySQL 容器执行入口。用 MYSQL_PWD 传密码,避免密码出现在
|
||
# 进程列表里,也避开 mysql 客户端的 "password on the command line" 告警。
|
||
db_exec() {
|
||
docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SVC_DB" "$@"
|
||
}
|
||
|
||
db_query() {
|
||
db_exec mysql -u root -N -B -e "$1" 2>/dev/null | tr -d '\r'
|
||
}
|
||
|
||
db_running() {
|
||
local cid
|
||
cid=$(docker compose ps -a -q "$SVC_DB" 2>/dev/null | head -1)
|
||
[ -n "$cid" ] || return 1
|
||
[ "$(docker inspect "$cid" --format '{{.State.Running}}' 2>/dev/null)" = "true" ]
|
||
}
|
||
|
||
compose_container_id() {
|
||
docker compose ps -a -q "$SVC_APP" 2>/dev/null | head -1 || true
|
||
}
|
||
|
||
running_image_id() {
|
||
local cid
|
||
cid="$(compose_container_id)"
|
||
[ -n "$cid" ] || return 0
|
||
docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true
|
||
}
|
||
|
||
preflight() {
|
||
step "环境预检"
|
||
|
||
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限运行"; exit 1; }
|
||
|
||
local missing=0
|
||
for cmd in docker curl tar gzip; do
|
||
command -v "$cmd" >/dev/null 2>&1 || { error "缺少命令: $cmd"; missing=1; }
|
||
done
|
||
docker compose version >/dev/null 2>&1 || { error "docker compose 不可用"; missing=1; }
|
||
[ "$missing" -eq 0 ] || exit 1
|
||
|
||
[ -f docker-compose.yml ] || { error "docker-compose.yml 不存在"; exit 1; }
|
||
[ -d "$DATA_DIR" ] || { error "数据目录不存在: $DATA_DIR"; exit 1; }
|
||
|
||
# MySQL 必须在跑:备份和行数比对都依赖它,且升级全程它都不会被重建
|
||
if ! db_running; then
|
||
error "MySQL 容器未运行,请先启动: docker compose up -d $SVC_DB"
|
||
exit 1
|
||
fi
|
||
if ! db_query "SELECT 1;" >/dev/null 2>&1; then
|
||
error "无法连接 MySQL,请检查 DB_ROOT_PASSWORD 是否与容器一致"
|
||
exit 1
|
||
fi
|
||
log "MySQL 连接正常"
|
||
|
||
# 确认 Gitea 已完成安装向导,否则没有可保护的数据
|
||
local ntables
|
||
ntables=$(db_query "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${DB_NAME}';" || true)
|
||
if ! [[ "$ntables" =~ ^[0-9]+$ ]] || [ "$ntables" -lt 10 ]; then
|
||
error "数据库 ${DB_NAME} 中只有 ${ntables:-0} 张表,Gitea 似乎尚未完成安装向导"
|
||
error "没有数据需要保护,直接改 .env 里的 GITEA_IMAGE 重新部署即可"
|
||
exit 1
|
||
fi
|
||
log "数据库 ${DB_NAME} 共 ${ntables} 张表"
|
||
|
||
collect_backup_paths
|
||
|
||
# 磁盘空间:SQL 备份按数据库物理体积的 1.5 倍估(压缩后通常远小于此),
|
||
# 数据备份按选中目录体积的 1.2 倍估,再留 500MB 余量
|
||
mkdir -p "$BACKUP_BASE"
|
||
local db_kb data_kb need_kb avail_kb
|
||
db_kb=$(db_query "SELECT IFNULL(CEIL(SUM(data_length+index_length)/1024),0) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${DB_NAME}';" || true)
|
||
[[ "$db_kb" =~ ^[0-9]+$ ]] || db_kb=0
|
||
data_kb=$(backup_paths_size_kb)
|
||
avail_kb=$(df -Pk "$BACKUP_BASE" | awk 'NR==2{print $4}')
|
||
[ -n "$avail_kb" ] || { error "无法检测 $BACKUP_BASE 的可用空间"; exit 1; }
|
||
need_kb=$(( db_kb * 3 / 2 + data_kb * 6 / 5 + 512000 ))
|
||
if [ "$avail_kb" -lt "$need_kb" ]; then
|
||
error "磁盘空间不足: 可用 $((avail_kb/1024))MB,预计需要 $((need_kb/1024))MB"
|
||
error "可清理旧备份: ls -lh $BACKUP_BASE"
|
||
exit 1
|
||
fi
|
||
|
||
log "root 权限、依赖命令、数据目录、磁盘空间 均正常"
|
||
info "数据库 $((db_kb/1024))MB,待备份数据 $((data_kb/1024))MB,可用空间 $((avail_kb/1024))MB"
|
||
}
|
||
|
||
# =============================================================
|
||
# 备份范围
|
||
# =============================================================
|
||
# Gitea 升级只改数据库结构和少量元数据;仓库对象、LFS、软件包是内容寻址的
|
||
# 追加式存储,迁移不会改写它们。默认因此跳过这些大块,否则一个上百 GB 的
|
||
# 实例根本备份不动 —— 但配置、各类密钥、索引必须备,丢了会话和 OAuth 全废。
|
||
collect_backup_paths() {
|
||
BACKUP_PATHS=()
|
||
local base rel
|
||
base="$(basename "$DATA_DIR")"
|
||
|
||
local always=(
|
||
"gitea/conf" # app.ini
|
||
"gitea/jwt" # OAuth2 签名密钥,丢了所有令牌失效
|
||
"gitea/https"
|
||
"ssh" # SSH host key,丢了所有客户端报中间人攻击
|
||
"gitea/indexers" # 代码/议题索引,与版本相关
|
||
"gitea/queues" # 持久化队列(LevelDB),跨版本格式可能变
|
||
"gitea/sessions"
|
||
"gitea/avatars"
|
||
"gitea/repo-avatars"
|
||
)
|
||
# 官方镜像的仓库根是 /data/git/repositories、LFS 在 /data/gitea/lfs,
|
||
# 但 app.ini 里这些路径可以改。多列几个候选,反正下面按存在与否过滤,
|
||
# 漏备份比多备份危险得多。
|
||
local heavy=(
|
||
"git/repositories"
|
||
"gitea/repositories"
|
||
"git/lfs"
|
||
"gitea/lfs"
|
||
"gitea/packages"
|
||
"gitea/attachments"
|
||
"gitea/actions_log"
|
||
"gitea/actions_artifacts"
|
||
)
|
||
|
||
for rel in "${always[@]}"; do
|
||
[ -e "$DATA_DIR/$rel" ] && BACKUP_PATHS+=("$base/$rel")
|
||
done
|
||
if [ "$FULL_BACKUP" -eq 1 ]; then
|
||
for rel in "${heavy[@]}"; do
|
||
[ -e "$DATA_DIR/$rel" ] && BACKUP_PATHS+=("$base/$rel")
|
||
done
|
||
fi
|
||
|
||
[ "${#BACKUP_PATHS[@]}" -gt 0 ] || {
|
||
error "在 $DATA_DIR 下找不到任何 Gitea 数据子目录,路径是否正确?"
|
||
exit 1
|
||
}
|
||
}
|
||
|
||
backup_paths_size_kb() {
|
||
local parent total=0 p sz
|
||
parent="$(dirname "$DATA_DIR")"
|
||
for p in "${BACKUP_PATHS[@]}"; do
|
||
sz=$(du -sk "$parent/$p" 2>/dev/null | cut -f1 || echo 0)
|
||
[[ "$sz" =~ ^[0-9]+$ ]] || sz=0
|
||
total=$(( total + sz ))
|
||
done
|
||
echo "$total"
|
||
}
|
||
|
||
# =============================================================
|
||
# 版本解析
|
||
# =============================================================
|
||
resolve_target_version() {
|
||
if [ -n "$TARGET_VERSION" ]; then
|
||
info "使用指定版本: $TARGET_VERSION"
|
||
return 0
|
||
fi
|
||
info "查询 GitHub 最新 release..."
|
||
TARGET_VERSION=$(curl -sf --max-time 20 \
|
||
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
|
||
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
|
||
|
||
if [ -z "$TARGET_VERSION" ]; then
|
||
error "无法从 GitHub 获取最新版本(网络问题?)"
|
||
error "请手动指定: bash upgrade.sh --version 1.27.1"
|
||
exit 1
|
||
fi
|
||
log "GitHub 最新 release: $TARGET_VERSION"
|
||
}
|
||
|
||
# 当前运行版本:优先问容器里的二进制(不受 REQUIRE_SIGNIN 影响),
|
||
# 拿不到再退回 HTTP 接口
|
||
detect_current_version() {
|
||
local cid v=""
|
||
cid="$(compose_container_id)"
|
||
if [ -n "$cid" ] && [ "$(docker inspect "$cid" --format '{{.State.Running}}' 2>/dev/null)" = "true" ]; then
|
||
v=$(docker exec "$cid" gitea --version 2>/dev/null | head -1 | awk '{print $3}' | cut -d+ -f1 || true)
|
||
fi
|
||
if [ -z "$v" ]; then
|
||
v=$(curl -sf --max-time 10 "http://127.0.0.1:3000/api/v1/version" 2>/dev/null \
|
||
| grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||
| sed 's/.*"\([^"]*\)"$/\1/' | cut -d+ -f1 || true)
|
||
fi
|
||
echo "$v"
|
||
}
|
||
|
||
# Gitea 数据库迁移版本号,是判断「结构是否被改过」最直接的指标
|
||
get_migration_version() {
|
||
db_query "SELECT version FROM \`${DB_NAME}\`.version WHERE id=1;" | head -1
|
||
}
|
||
|
||
check_version_transition() {
|
||
local cur="$1" tgt="$2"
|
||
|
||
if [ -z "$cur" ]; then
|
||
warn "无法探测当前 Gitea 版本,跳过版本路径检查"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$cur" = "$tgt" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if ver_lt "$tgt" "$cur"; then
|
||
# 降级:同 major.minor 的补丁降级结构不变,尚可接受;跨小版本降级
|
||
# 会让新版数据库撞上旧版二进制,Gitea 会直接拒绝启动
|
||
if [ "$(ver_series "$cur")" = "$(ver_series "$tgt")" ]; then
|
||
warn "这是补丁级降级 ${cur} → ${tgt},数据库结构不变,风险可控"
|
||
else
|
||
error "拒绝执行跨小版本降级: ${cur} → ${tgt}"
|
||
error "Gitea 的数据库迁移是单向的,旧版二进制遇到新版数据库会直接退出:"
|
||
error " \"newer database for this old Gitea release ... Gitea will exit\""
|
||
error "要退回旧版本,请用升级前的备份回滚:"
|
||
error " bash upgrade.sh --rollback"
|
||
exit 1
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
if [ "$(ver_series "$cur")" != "$(ver_series "$tgt")" ]; then
|
||
echo ""
|
||
warn "跨小版本升级 ${cur} → ${tgt}:Gitea 会在启动时改写数据库结构"
|
||
warn "这个过程不可逆 —— 之后只能靠本脚本的 SQL 备份回滚,不能只切镜像"
|
||
warn "建议先读一遍变更日志(尤其是自定义模板相关的破坏性变更):"
|
||
warn " https://github.com/${GITHUB_REPO}/blob/main/CHANGELOG.md"
|
||
else
|
||
info "补丁级升级 ${cur} → ${tgt},数据库结构不变"
|
||
fi
|
||
}
|
||
|
||
# =============================================================
|
||
# 数据指纹
|
||
# =============================================================
|
||
snapshot_counts() {
|
||
local existing t sql="" out=""
|
||
existing=$(db_query "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='${DB_NAME}';")
|
||
[ -n "$existing" ] || return 1
|
||
|
||
for t in $FINGERPRINT_TABLES; do
|
||
grep -qx "$t" <<<"$existing" || continue
|
||
[ -n "$sql" ] && sql+=" UNION ALL "
|
||
sql+="SELECT '$t', COUNT(*) FROM \`${DB_NAME}\`.\`$t\`"
|
||
done
|
||
[ -n "$sql" ] || return 1
|
||
|
||
out=$(db_query "$sql;" | awk 'NF==2{printf "%s=%s ", $1, $2}')
|
||
[ -n "$out" ] || return 1
|
||
echo "${out% }"
|
||
}
|
||
|
||
print_counts() {
|
||
local label="$1" counts="$2" kv
|
||
echo " ${label}:"
|
||
for kv in $counts; do
|
||
printf " %-18s %s\n" "${kv%%=*}" "${kv##*=}"
|
||
done
|
||
}
|
||
|
||
# =============================================================
|
||
# 备份
|
||
# =============================================================
|
||
cold_backup() {
|
||
step "冷备份(Gitea 已停止,MySQL 仍在运行)"
|
||
|
||
mkdir -p "$BACKUP_BASE"
|
||
chmod 700 "$BACKUP_BASE"
|
||
|
||
COUNTS_BEFORE="$(snapshot_counts)" || { error "无法统计数据表行数"; exit 1; }
|
||
MIGRATION_BEFORE="$(get_migration_version || true)"
|
||
print_counts "升级前数据统计" "$COUNTS_BEFORE"
|
||
info "数据库迁移版本: ${MIGRATION_BEFORE:-未知}"
|
||
|
||
# ---- 1. 数据库 ----
|
||
DB_BACKUP="${BACKUP_BASE}/pre-upgrade-db-${TIMESTAMP}.sql.gz"
|
||
info "导出数据库 → $DB_BACKUP"
|
||
|
||
local dump_opts=(-u root --single-transaction --routines --triggers
|
||
--hex-blob --default-character-set=utf8mb4 --databases "$DB_NAME")
|
||
# 无 GTID 的实例上 mysqldump 会写入 SET @@GLOBAL.GTID_PURGED,回灌时报错;
|
||
# MariaDB 没有这个选项,所以先探测再决定加不加。
|
||
# 先把 --help 收进变量再匹配,不能写成 `... | grep -q`:grep -q 命中即退出会让
|
||
# 上游收到 SIGPIPE,在 pipefail 下整条管道被判为失败,探测结果就反了。
|
||
local dump_help
|
||
dump_help="$(db_exec mysqldump --help 2>/dev/null || true)"
|
||
if grep -q 'set-gtid-purged' <<<"$dump_help"; then
|
||
dump_opts+=(--set-gtid-purged=OFF)
|
||
fi
|
||
|
||
if ! db_exec mysqldump "${dump_opts[@]}" | gzip > "$DB_BACKUP"; then
|
||
error "数据库导出失败,服务未做不可逆改动"
|
||
exit 1
|
||
fi
|
||
verify_db_backup "$DB_BACKUP"
|
||
log "数据库备份完成: $(du -h "$DB_BACKUP" | cut -f1)"
|
||
|
||
# ---- 2. 数据目录(按 collect_backup_paths 选定的范围)----
|
||
DATA_BACKUP="${BACKUP_BASE}/pre-upgrade-data-${TIMESTAMP}.tar.gz"
|
||
info "打包数据目录 → $DATA_BACKUP"
|
||
if [ "$FULL_BACKUP" -eq 1 ]; then
|
||
info " 模式: 全量(含仓库/LFS/包),可能耗时较久"
|
||
else
|
||
info " 模式: 增量(配置/密钥/索引/头像),仓库与 LFS 未打包"
|
||
fi
|
||
printf ' %s\n' "${BACKUP_PATHS[@]}"
|
||
|
||
tar czf "$DATA_BACKUP" -C "$(dirname "$DATA_DIR")" "${BACKUP_PATHS[@]}"
|
||
verify_data_backup "$DATA_BACKUP"
|
||
log "数据备份完成: $(du -h "$DATA_BACKUP" | cut -f1)"
|
||
|
||
# ---- 3. 部署文件 ----
|
||
META_BACKUP="${BACKUP_BASE}/pre-upgrade-meta-${TIMESTAMP}.tar.gz"
|
||
local meta_items=(.env docker-compose.yml)
|
||
[ -d nginx ] && meta_items+=(nginx)
|
||
tar czf "$META_BACKUP" -C "$SCRIPT_DIR" "${meta_items[@]}"
|
||
chmod 600 "$META_BACKUP" "$DB_BACKUP"
|
||
log "部署文件备份完成: $META_BACKUP"
|
||
}
|
||
|
||
verify_db_backup() {
|
||
local f="$1"
|
||
info "校验 SQL 备份..."
|
||
|
||
gzip -t "$f" 2>/dev/null || { error "SQL 备份 gzip 校验失败,文件已损坏"; exit 1; }
|
||
|
||
# 一次解压同时数建表语句和找结束标记。awk 会读完整个流,因此不会像
|
||
# `gunzip -c | grep -q` 那样让上游收到 SIGPIPE —— 在 pipefail 下那会把
|
||
# 好备份误判成坏的。"Dump completed" 只有 mysqldump 正常收尾才会写,
|
||
# 中途被 OOM / 断连截断的备份没有这行。
|
||
local summary tables completed
|
||
summary="$(gunzip -c "$f" | awk '
|
||
/^CREATE TABLE / { n++ }
|
||
/-- Dump completed/{ done = 1 }
|
||
END { print n+0, done+0 }
|
||
')" || { error "SQL 备份无法完整解压"; exit 1; }
|
||
|
||
tables="${summary%% *}"
|
||
completed="${summary##* }"
|
||
|
||
if [ "$completed" != "1" ]; then
|
||
error "SQL 备份没有结束标记,导出被截断了"
|
||
exit 1
|
||
fi
|
||
if [ "$tables" -lt 10 ]; then
|
||
error "SQL 备份中只有 ${tables} 条 CREATE TABLE,内容不完整"
|
||
exit 1
|
||
fi
|
||
log "SQL 备份校验通过(${tables} 张表,含结束标记)"
|
||
}
|
||
|
||
verify_data_backup() {
|
||
local f="$1" listing
|
||
info "校验数据备份包..."
|
||
listing="$(tar tzf "$f")" || { error "数据备份包损坏,无法列出内容"; exit 1; }
|
||
grep -q 'gitea/conf/app\.ini$' <<<"$listing" || warn "备份包中没有 app.ini"
|
||
grep -q '/ssh/' <<<"$listing" || warn "备份包中没有 SSH host key 目录"
|
||
log "数据备份包可正常读取"
|
||
}
|
||
|
||
# =============================================================
|
||
# 镜像与容器
|
||
# =============================================================
|
||
tag_rollback_image() {
|
||
step "锚定当前镜像(用于回滚)"
|
||
|
||
local image_id
|
||
image_id="$(running_image_id)"
|
||
if [ -z "$image_id" ]; then
|
||
image_id=$(docker image inspect "$CURRENT_IMAGE" --format '{{.Id}}' 2>/dev/null || true)
|
||
fi
|
||
[ -n "$image_id" ] || { error "找不到当前运行的镜像,无法建立回滚点"; exit 1; }
|
||
|
||
ROLLBACK_TAG="${IMAGE_REPO}:pre-upgrade-${TIMESTAMP}"
|
||
docker tag "$image_id" "$ROLLBACK_TAG"
|
||
OLD_IMAGE_REF="$CURRENT_IMAGE"
|
||
|
||
log "当前镜像已标记为 $ROLLBACK_TAG"
|
||
info "镜像 ID: ${image_id#sha256:}"
|
||
}
|
||
|
||
pull_target_image() {
|
||
step "拉取目标镜像(此步骤不影响运行中的服务)"
|
||
|
||
local target="${IMAGE_REPO}:${TARGET_VERSION}"
|
||
info "docker pull $target ..."
|
||
if ! docker pull "$target"; then
|
||
error "镜像拉取失败,服务未做任何改动,可安全重试"
|
||
error "国内服务器可在 .env 的 DOCKER_REGISTRY_MIRRORS 里换个加速源"
|
||
exit 1
|
||
fi
|
||
log "镜像已就绪: $target"
|
||
}
|
||
|
||
switch_image_in_env() {
|
||
local new_ref="$1"
|
||
[ -f ".env.bak-${TIMESTAMP}" ] || { cp .env ".env.bak-${TIMESTAMP}"; chmod 600 ".env.bak-${TIMESTAMP}"; }
|
||
if grep -q '^GITEA_IMAGE=' .env; then
|
||
sed -i "s|^GITEA_IMAGE=.*|GITEA_IMAGE=${new_ref}|" .env
|
||
else
|
||
printf '\nGITEA_IMAGE=%s\n' "$new_ref" >> .env
|
||
fi
|
||
# 必须同步更新导出的变量:load_env 里 `set -a; source .env` 已把旧值导出到
|
||
# 本进程环境,而 docker compose 对环境变量的优先级高于 .env 文件 —— 只改文件
|
||
# 的话 compose 仍会读到旧镜像,表现为「容器重建了但版本没变」。
|
||
export GITEA_IMAGE="$new_ref"
|
||
info ".env 中 GITEA_IMAGE → $new_ref"
|
||
}
|
||
|
||
# --no-deps 是关键:不加的话 --force-recreate 会连 MySQL 容器一起重建,
|
||
# 平白多一次数据库停机,也多一份风险。db 全程保持运行。
|
||
recreate_app() {
|
||
docker compose up -d --force-recreate --no-deps "$SVC_APP"
|
||
}
|
||
|
||
# curl 的 -w '%{http_code}' 在连不上时「既向 stdout 输出 000,又返回非零退出码」。
|
||
# 因此绝不能写成 `code=$(curl ... || echo "000")` —— 那会让两个 000 拼成 "000000",
|
||
# 于是 `[ "$code" != "000" ]` 恒成立,wait_healthy 会在服务根本没起来时就误判就绪,
|
||
# 而 verify_endpoints 又认不出这个状态码,最终把一次正常的迁移误杀成回滚。
|
||
http_code() {
|
||
local url="$1" timeout="${2:-8}" code
|
||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$timeout" "$url" 2>/dev/null)" || true
|
||
[[ "$code" =~ ^[0-9]{3}$ ]] || code="000"
|
||
echo "$code"
|
||
}
|
||
|
||
# Gitea 在跑完数据库迁移之后才会监听 HTTP 端口,所以「端口有响应」
|
||
# 就等价于「迁移已完成」。返回任意 HTTP 状态码都算就绪 —— 开了
|
||
# REQUIRE_SIGNIN 时 /api/v1/version 会返回 401/403,那也是活着。
|
||
wait_healthy() {
|
||
local max_wait="${1:-$READY_TIMEOUT}" i cid code state restarts base_restarts
|
||
cid="$(compose_container_id)"
|
||
base_restarts=$(docker inspect "$cid" --format '{{.RestartCount}}' 2>/dev/null || echo 0)
|
||
|
||
info "等待 Gitea 就绪(最多 ${max_wait}s,跨版本迁移会比较慢)..."
|
||
for i in $(seq 1 "$max_wait"); do
|
||
code="$(http_code "http://127.0.0.1:3000/api/v1/version" 3)"
|
||
if [ "$code" != "000" ]; then
|
||
log "Gitea 已就绪(${i}s,HTTP ${code})"
|
||
return 0
|
||
fi
|
||
|
||
# 迁移失败会让容器退出,restart:always 把它拉起来反复失败。
|
||
# 与其干等到超时,不如发现重启就立刻收手。
|
||
state=$(docker inspect "$cid" --format '{{.State.Status}}' 2>/dev/null || echo "unknown")
|
||
restarts=$(docker inspect "$cid" --format '{{.RestartCount}}' 2>/dev/null || echo 0)
|
||
if [ "$restarts" -gt "$base_restarts" ]; then
|
||
error "容器已重启 $((restarts - base_restarts)) 次,启动失败(多半是迁移报错)"
|
||
return 1
|
||
fi
|
||
if [ "$state" = "exited" ] || [ "$state" = "dead" ]; then
|
||
error "容器状态为 $state,启动失败"
|
||
return 1
|
||
fi
|
||
|
||
[ $(( i % 30 )) -eq 0 ] && info " 已等待 ${i}s,仍在启动中..."
|
||
sleep 1
|
||
done
|
||
error "等待超时(${max_wait}s)"
|
||
return 1
|
||
}
|
||
|
||
# =============================================================
|
||
# 验证
|
||
# =============================================================
|
||
verify_running_image() {
|
||
local expect="$1" cid actual_ref actual_id expect_id
|
||
cid="$(compose_container_id)"
|
||
[ -n "$cid" ] || { error "找不到容器"; return 1; }
|
||
|
||
actual_ref=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
|
||
actual_id=$(docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true)
|
||
expect_id=$(docker image inspect "$expect" --format '{{.Id}}' 2>/dev/null || true)
|
||
|
||
if [ "$actual_id" != "$expect_id" ]; then
|
||
error "容器未运行在目标镜像上"
|
||
error " 期望: $expect ($expect_id)"
|
||
error " 实际: $actual_ref ($actual_id)"
|
||
return 1
|
||
fi
|
||
log "容器镜像确认: $actual_ref"
|
||
return 0
|
||
}
|
||
|
||
verify_version() {
|
||
local expect="$1" actual
|
||
actual="$(detect_current_version)"
|
||
if [ -z "$actual" ]; then
|
||
warn "无法读取运行版本,跳过版本校验(镜像 ID 校验已通过)"
|
||
return 0
|
||
fi
|
||
if [ "$actual" != "$expect" ]; then
|
||
error "运行版本与目标不符: 期望 $expect,实际 $actual"
|
||
return 1
|
||
fi
|
||
log "运行版本确认: $actual"
|
||
return 0
|
||
}
|
||
|
||
verify_endpoints() {
|
||
step "接口验证"
|
||
local failed=0 code
|
||
|
||
code="$(http_code "http://127.0.0.1:3000/api/v1/version" 10)"
|
||
case "$code" in
|
||
200) log "/api/v1/version → 200" ;;
|
||
401|403) log "/api/v1/version → ${code}(已开启强制登录,属预期)" ;;
|
||
000) error "/api/v1/version 无响应"; failed=1 ;;
|
||
*) error "/api/v1/version → ${code}"; failed=1 ;;
|
||
esac
|
||
|
||
# 首页:模板损坏(自定义模板与新版不兼容)会在这里暴露成 5xx
|
||
code="$(http_code "http://127.0.0.1:3000/" 10)"
|
||
if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then
|
||
error "首页 → ${code}(模板渲染失败?)"; failed=1
|
||
else
|
||
log "首页 → ${code}"
|
||
fi
|
||
|
||
# 内置 SSH 服务:仓库 clone 依赖它。
|
||
# 注意不能只测「TCP 能否连上」—— docker-proxy 会抢先接受连接,哪怕容器里
|
||
# 根本没有服务在监听,那样测出来永远是通的。必须实际读到 SSH 版本 banner。
|
||
# 而且只能按行读(banner 以 CRLF 结尾),不能用 `head -c N`:banner 只有
|
||
# 十来个字节,服务端在客户端应答前不会再发数据,head -c 会一直阻塞到超时
|
||
# 被杀、什么都读不出来,把一个健康的服务误报成故障。
|
||
local ssh_port="${SSH_PORT:-2222}" banner
|
||
banner="$(timeout 6 bash -c "exec 3<>/dev/tcp/127.0.0.1/${ssh_port}; head -n 1 <&3" 2>/dev/null | tr -d '\r\n' || true)"
|
||
if [[ "$banner" == SSH-* ]]; then
|
||
log "SSH 端口 ${ssh_port} 正常(banner: ${banner})"
|
||
else
|
||
warn "SSH 端口 ${ssh_port} 未返回 SSH banner,Git clone 可能不可用"
|
||
fi
|
||
|
||
# 经 Nginx 的外部访问:失败只告警,属代理层问题,不触发回滚
|
||
if [ -n "$DOMAIN" ]; then
|
||
code="$(http_code "https://${DOMAIN}/api/v1/version" 15)"
|
||
if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then
|
||
log "https://${DOMAIN}/api/v1/version → ${code}(Nginx 反代正常)"
|
||
else
|
||
warn "https://${DOMAIN}/api/v1/version → ${code}(检查 Nginx/证书,不影响升级判定)"
|
||
fi
|
||
fi
|
||
|
||
return $failed
|
||
}
|
||
|
||
verify_data_integrity() {
|
||
step "数据完整性验证"
|
||
|
||
local counts_after migration_after
|
||
counts_after="$(snapshot_counts)" || { error "升级后无法统计数据表行数"; return 1; }
|
||
migration_after="$(get_migration_version || true)"
|
||
|
||
print_counts "升级后数据统计" "$counts_after"
|
||
info "数据库迁移版本: ${MIGRATION_BEFORE:-未知} → ${migration_after:-未知}"
|
||
|
||
# 迁移版本只能前进。倒退意味着连错了库或数据被回灌成了旧状态
|
||
if [[ "$MIGRATION_BEFORE" =~ ^[0-9]+$ ]] && [[ "$migration_after" =~ ^[0-9]+$ ]]; then
|
||
if [ "$migration_after" -lt "$MIGRATION_BEFORE" ]; then
|
||
error "数据库迁移版本倒退: $MIGRATION_BEFORE → $migration_after"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# 逐表比对:行数只允许持平或增加,减少视为数据丢失
|
||
local kv table before after lost=0
|
||
for kv in $COUNTS_BEFORE; do
|
||
table="${kv%%=*}"; before="${kv##*=}"
|
||
after=$(tr ' ' '\n' <<<"$counts_after" | grep "^${table}=" | cut -d= -f2 || true)
|
||
# 升级后表被重命名/合并(Gitea 迁移偶有此事)→ 跳过,不误判为丢数据
|
||
if [ -z "$after" ]; then
|
||
warn "表 $table 在新版中不存在(可能被迁移重命名),跳过比对"
|
||
continue
|
||
fi
|
||
if [ "$after" -lt "$before" ]; then
|
||
error "表 $table 行数减少: $before → $after"
|
||
lost=1
|
||
fi
|
||
done
|
||
|
||
if [ "$lost" -eq 1 ]; then
|
||
error "检测到数据丢失!"
|
||
return 1
|
||
fi
|
||
log "所有表行数无减少,数据完好"
|
||
return 0
|
||
}
|
||
|
||
# Gitea 自带的一致性检查。只作提示,任何结果都不触发回滚 ——
|
||
# doctor 经常报一些历史遗留的良性问题,拿它当回滚依据会误伤。
|
||
run_doctor() {
|
||
step "gitea doctor 一致性检查(仅提示)"
|
||
local cid
|
||
cid="$(compose_container_id)"
|
||
[ -n "$cid" ] || { warn "找不到容器,跳过"; return 0; }
|
||
|
||
if docker exec -u git "$cid" gitea doctor check \
|
||
--config /data/gitea/conf/app.ini 2>&1 | tail -40; then
|
||
log "doctor 检查结束"
|
||
else
|
||
warn "doctor 报告了问题(见上方输出),不影响本次升级判定"
|
||
warn "如需修复: docker compose exec -u git $SVC_APP gitea doctor check --fix"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# =============================================================
|
||
# 回滚
|
||
# =============================================================
|
||
save_state() {
|
||
cat > "$STATE_FILE" <<EOF
|
||
# Gitea 升级状态记录 —— 由 upgrade.sh 自动生成
|
||
UPGRADE_TIMESTAMP=${TIMESTAMP}
|
||
FROM_VERSION=${CURRENT_VERSION}
|
||
TO_VERSION=${TARGET_VERSION}
|
||
DB_BACKUP=${DB_BACKUP}
|
||
DATA_BACKUP=${DATA_BACKUP}
|
||
META_BACKUP=${META_BACKUP}
|
||
FULL_BACKUP=${FULL_BACKUP}
|
||
ROLLBACK_TAG=${ROLLBACK_TAG}
|
||
OLD_IMAGE_REF=${OLD_IMAGE_REF}
|
||
NEW_IMAGE_REF=${IMAGE_REPO}:${TARGET_VERSION}
|
||
DATA_DIR=${DATA_DIR}
|
||
MIGRATION_BEFORE=${MIGRATION_BEFORE}
|
||
ENV_BACKUP=${SCRIPT_DIR}/.env.bak-${TIMESTAMP}
|
||
EOF
|
||
chmod 600 "$STATE_FILE"
|
||
}
|
||
|
||
# 回灌 SQL 备份。这是 Gitea 回滚的核心 —— 光切镜像没用,
|
||
# 旧版二进制看到新版结构的库会拒绝启动。
|
||
restore_database() {
|
||
local dump="$1"
|
||
[ -f "$dump" ] || { error "SQL 备份不存在: $dump"; return 1; }
|
||
|
||
info "回灌数据库备份 $dump ..."
|
||
gzip -t "$dump" 2>/dev/null || { error "SQL 备份已损坏,无法恢复"; return 1; }
|
||
|
||
# 备份用的是 --databases(不带 --add-drop-database),每张表自带
|
||
# DROP TABLE IF EXISTS,逐表原样替换,不会动 MySQL 里的用户与授权。
|
||
# 新版新增的孤儿表会留下,旧版 Gitea 会忽略它们,无害。
|
||
if ! gunzip -c "$dump" | db_exec mysql -u root; then
|
||
error "数据库回灌失败"
|
||
return 1
|
||
fi
|
||
|
||
local n mv
|
||
n=$(db_query "SELECT COUNT(*) FROM \`${DB_NAME}\`.\`user\`;" 2>/dev/null | head -1 || true)
|
||
[[ "$n" =~ ^[0-9]+$ ]] || { error "恢复后无法读取 user 表"; return 1; }
|
||
mv="$(get_migration_version || true)"
|
||
log "数据库已恢复(user 表 ${n} 行,迁移版本 ${mv:-未知})"
|
||
return 0
|
||
}
|
||
|
||
# 合并式解压:只覆盖备份包里有的路径。
|
||
# 绝不对数据目录做 rm / mv —— 那底下可能是几百 GB 的仓库。
|
||
restore_data_dir() {
|
||
local tarball="$1"
|
||
[ -f "$tarball" ] || { error "数据备份不存在: $tarball"; return 1; }
|
||
|
||
local parent
|
||
parent="$(dirname "$DATA_DIR")"
|
||
|
||
info "恢复数据目录(合并覆盖,仓库不受影响)..."
|
||
tar xzf "$tarball" -C "$parent" || { error "数据目录恢复失败"; return 1; }
|
||
|
||
# root 解压时 GNU tar 默认已还原归档中的属主,这里只是再补一道保险。
|
||
# 只 chown 归档里真正列出的路径 —— 绝不对 $DATA_DIR 整体递归 chown,
|
||
# 那底下可能挂着上百 GB 的 LFS 与仓库,递归一遍要跑很久且毫无必要。
|
||
tar tzf "$tarball" | sed "s|^|${parent}/|" | tr '\n' '\0' \
|
||
| xargs -0 --no-run-if-empty chown -h 1000:1000 2>/dev/null || true
|
||
|
||
log "数据目录已从备份恢复"
|
||
return 0
|
||
}
|
||
|
||
# 服务已停、但备份尚未就绪时的紧急恢复:此阶段 .env 与数据都未改动
|
||
emergency_start_old() {
|
||
step "紧急恢复"
|
||
warn "升级在备份阶段中止,数据未被改动,正在拉起原服务..."
|
||
docker compose up -d "$SVC_APP" || true
|
||
if wait_healthy 120; then
|
||
log "原服务已恢复运行,本次升级未造成任何影响"
|
||
else
|
||
error "原服务恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d"
|
||
error "查看日志: docker compose logs --tail 100 $SVC_APP"
|
||
fi
|
||
}
|
||
|
||
on_exit() {
|
||
local code=$?
|
||
trap - EXIT ERR
|
||
[ "$code" -eq 0 ] && return 0
|
||
case "$SAFETY_STAGE" in
|
||
stopped) emergency_start_old ;;
|
||
armed) do_rollback "脚本异常中止(退出码 $code)" ;;
|
||
*) : ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
do_rollback() {
|
||
trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归
|
||
SAFETY_STAGE="done"
|
||
set +e
|
||
local reason="$1"
|
||
step "自动回滚"
|
||
error "触发原因: $reason"
|
||
|
||
docker compose stop "$SVC_APP" || true
|
||
|
||
restore_database "$DB_BACKUP" || {
|
||
error "数据库自动恢复失败!请手动处理:"
|
||
error " gunzip -c $DB_BACKUP | docker compose exec -T $SVC_DB mysql -u root -p"
|
||
error "备份文件保留在: $DB_BACKUP"
|
||
exit 1
|
||
}
|
||
restore_data_dir "$DATA_BACKUP" || {
|
||
error "数据目录恢复失败,请手动处理:"
|
||
error " tar xzf $DATA_BACKUP -C $(dirname "$DATA_DIR")"
|
||
exit 1
|
||
}
|
||
switch_image_in_env "$ROLLBACK_TAG"
|
||
recreate_app
|
||
|
||
if wait_healthy 180; then
|
||
log "回滚完成,已恢复到升级前状态(${CURRENT_VERSION:-旧版本})"
|
||
else
|
||
error "回滚后服务仍未就绪,请检查: docker compose logs --tail 100 $SVC_APP"
|
||
fi
|
||
echo ""
|
||
warn "升级失败已回滚。备份保留在:"
|
||
warn " 数据库: $DB_BACKUP"
|
||
warn " 数据: $DATA_BACKUP"
|
||
exit 1
|
||
}
|
||
|
||
manual_rollback() {
|
||
step "手动回滚"
|
||
|
||
[ -f "$STATE_FILE" ] || { error "找不到升级记录 $STATE_FILE,无法自动回滚"; exit 1; }
|
||
# shellcheck disable=SC1090
|
||
source "$STATE_FILE"
|
||
|
||
echo ""
|
||
info "将回滚到:"
|
||
info " 版本: ${FROM_VERSION:-未知} (当前 ${TO_VERSION:-未知})"
|
||
info " 镜像: $ROLLBACK_TAG"
|
||
info " 数据库: $DB_BACKUP"
|
||
info " 数据: $DATA_BACKUP"
|
||
info " 时间: $UPGRADE_TIMESTAMP"
|
||
echo ""
|
||
|
||
[ -f "$DB_BACKUP" ] || { error "数据库备份不存在: $DB_BACKUP"; exit 1; }
|
||
[ -f "$DATA_BACKUP" ] || { error "数据备份不存在: $DATA_BACKUP"; exit 1; }
|
||
docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1 \
|
||
|| { error "回滚镜像不存在: $ROLLBACK_TAG"; exit 1; }
|
||
|
||
if ! db_running; then
|
||
error "MySQL 容器未运行,无法回灌数据库: docker compose up -d $SVC_DB"
|
||
exit 1
|
||
fi
|
||
|
||
warn "回滚会把数据库整体退回到 ${UPGRADE_TIMESTAMP} 的状态"
|
||
warn "升级之后产生的所有变更(新提交的 issue、PR、评论、新建仓库记录)都会丢失"
|
||
if [ "${FULL_BACKUP:-0}" != "1" ]; then
|
||
warn "本次备份未包含仓库与 LFS 文件,它们不会被改动 —— 但数据库回退后,"
|
||
warn "升级期间新建仓库的目录会成为数据库里查不到的孤儿目录(不影响使用)"
|
||
fi
|
||
confirm "确认回滚?" || { info "已取消"; exit 0; }
|
||
|
||
docker compose stop "$SVC_APP" || true
|
||
restore_database "$DB_BACKUP" || exit 1
|
||
restore_data_dir "$DATA_BACKUP" || exit 1
|
||
switch_image_in_env "$ROLLBACK_TAG"
|
||
recreate_app
|
||
|
||
if wait_healthy 180; then
|
||
log "回滚完成"
|
||
verify_endpoints || warn "接口验证有告警,请检查日志"
|
||
else
|
||
error "服务未就绪,请检查: docker compose logs --tail 100 $SVC_APP"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# =============================================================
|
||
# 主流程
|
||
# =============================================================
|
||
show_plan() {
|
||
echo ""
|
||
echo -e "${BOLD}══════════ 升级计划 ══════════${NC}"
|
||
echo " 域名: ${DOMAIN:-未配置}"
|
||
echo " 当前镜像: $CURRENT_IMAGE"
|
||
echo " 当前版本: ${CURRENT_VERSION:-未知(服务未运行?)}"
|
||
echo " 目标版本: ${IMAGE_REPO}:${TARGET_VERSION}"
|
||
echo " 数据目录: $DATA_DIR"
|
||
echo " 备份目录: $BACKUP_BASE"
|
||
echo " 备份范围: $([ "$FULL_BACKUP" -eq 1 ] && echo '全量(含仓库/LFS)' || echo '配置+密钥+索引(不含仓库/LFS)')"
|
||
echo ""
|
||
echo " 执行步骤:"
|
||
echo " 1. 拉取新镜像(失败则零影响退出)"
|
||
echo " 2. 锚定当前镜像为回滚点"
|
||
echo " 3. 停止 Gitea 容器(MySQL 保持运行)"
|
||
echo " 4. mysqldump + 打包数据目录 + 校验备份完整性"
|
||
echo " 5. 切换镜像并启动,Gitea 自动执行数据库迁移"
|
||
echo " 6. 验证镜像/版本/接口 + 比对表行数与迁移版本"
|
||
echo " 7. 任一验证失败 → 自动回滚(回灌 SQL + 切回旧镜像)"
|
||
echo ""
|
||
echo " 注意: MySQL 容器(mysql:8.4)不在本次升级范围内"
|
||
echo -e "${BOLD}═════════════════════════════${NC}"
|
||
}
|
||
|
||
main() {
|
||
parse_args "$@"
|
||
|
||
echo -e "${CYAN}${BOLD}"
|
||
echo " ____ _ _"
|
||
echo " / ___|(_) |_ ___ __ _"
|
||
echo "| | _ | | __/ _ \\/ _\` |"
|
||
echo "| |_| || | || __/ (_| |"
|
||
echo " \\____|_|\\__\\___|\\__,_| Upgrade Script"
|
||
echo -e "${NC}"
|
||
|
||
load_env
|
||
|
||
if [ "$MODE" = "rollback" ]; then
|
||
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限"; exit 1; }
|
||
manual_rollback
|
||
exit 0
|
||
fi
|
||
|
||
preflight
|
||
CURRENT_VERSION="$(detect_current_version)"
|
||
resolve_target_version
|
||
check_version_transition "$CURRENT_VERSION" "$TARGET_VERSION"
|
||
show_plan
|
||
|
||
# 已是目标版本?
|
||
local cur_image_id target_image_id
|
||
cur_image_id="$(running_image_id)"
|
||
target_image_id=$(docker image inspect "${IMAGE_REPO}:${TARGET_VERSION}" \
|
||
--format '{{.Id}}' 2>/dev/null || true)
|
||
if [ -n "$cur_image_id" ] && [ "$cur_image_id" = "$target_image_id" ]; then
|
||
echo ""
|
||
log "当前运行的已经是 ${TARGET_VERSION},无需升级"
|
||
[ "$MODE" = "check" ] && exit 0
|
||
confirm "仍要强制重新部署一次?" || { info "已取消"; exit 0; }
|
||
fi
|
||
|
||
if [ "$MODE" = "check" ]; then
|
||
echo ""
|
||
info "--check 模式,未做任何改动"
|
||
exit 0
|
||
fi
|
||
|
||
confirm "确认开始升级?" || { info "已取消"; exit 0; }
|
||
|
||
# 先拉镜像再打回滚标签:拉取失败时服务未动,也不留下无用的标签
|
||
pull_target_image
|
||
tag_rollback_image
|
||
|
||
# ===== 从这里开始服务会中断,全程由 on_exit 兜底 =====
|
||
trap on_exit EXIT
|
||
trap 'exit 1' ERR
|
||
|
||
step "停止 Gitea 容器"
|
||
SAFETY_STAGE="stopped" # 异常退出 → 直接拉起原版本
|
||
docker compose stop "$SVC_APP"
|
||
log "Gitea 已停止(MySQL 仍在运行,用于备份)"
|
||
|
||
cold_backup
|
||
save_state
|
||
SAFETY_STAGE="armed" # 备份已就位,异常退出 → 完整回滚
|
||
|
||
step "启动新版本(Gitea 将自动执行数据库迁移)"
|
||
switch_image_in_env "${IMAGE_REPO}:${TARGET_VERSION}"
|
||
# 必须 --force-recreate:容器处于 stopped 状态时,docker compose up -d 只会
|
||
# 把原容器重新 start,不会因为镜像变更而重建,结果是「升级了但还跑着旧镜像」。
|
||
recreate_app
|
||
|
||
if ! wait_healthy "$READY_TIMEOUT"; then
|
||
docker compose logs --tail 60 "$SVC_APP" || true
|
||
do_rollback "Gitea 在 ${READY_TIMEOUT} 秒内未就绪"
|
||
fi
|
||
if ! verify_running_image "${IMAGE_REPO}:${TARGET_VERSION}"; then
|
||
do_rollback "容器未运行在目标镜像上"
|
||
fi
|
||
if ! verify_version "$TARGET_VERSION"; then
|
||
do_rollback "运行版本与目标版本不符"
|
||
fi
|
||
if ! verify_endpoints; then
|
||
do_rollback "接口验证失败"
|
||
fi
|
||
if ! verify_data_integrity; then
|
||
do_rollback "数据完整性验证失败"
|
||
fi
|
||
|
||
# ===== 成功 =====
|
||
SAFETY_STAGE="done"
|
||
trap - EXIT ERR
|
||
|
||
[ "$RUN_DOCTOR" -eq 1 ] && run_doctor
|
||
|
||
step "升级成功"
|
||
echo ""
|
||
echo -e "${GREEN}${BOLD}══════════ 完成 ══════════${NC}"
|
||
echo " 版本: ${CURRENT_VERSION:-未知} → ${TARGET_VERSION}"
|
||
echo " 镜像: ${IMAGE_REPO}:${TARGET_VERSION}"
|
||
echo " 数据库备份: $DB_BACKUP"
|
||
echo " 数据备份: $DATA_BACKUP"
|
||
echo " 回滚镜像: $ROLLBACK_TAG"
|
||
echo ""
|
||
echo " 如需回滚: bash upgrade.sh --rollback"
|
||
echo " 查看日志: docker compose logs --tail 50 $SVC_APP"
|
||
echo -e "${GREEN}${BOLD}══════════════════════════${NC}"
|
||
echo ""
|
||
warn "请登录 Web 端确认仓库、议题、LFS 都正常后,再考虑清理回滚镜像:"
|
||
warn " docker image rm $ROLLBACK_TAG"
|
||
warn "升级前的备份请至少保留到确认无误之后,不要急着删。"
|
||
}
|
||
|
||
main "$@"
|