diff --git a/gitea/.gitignore b/gitea/.gitignore index 3dcecc5..4975579 100644 --- a/gitea/.gitignore +++ b/gitea/.gitignore @@ -1,3 +1,5 @@ data/ backups/ .env +.env.bak-* +.upgrade-state diff --git a/gitea/README.md b/gitea/README.md index 9ced08c..27fca5f 100644 --- a/gitea/README.md +++ b/gitea/README.md @@ -326,42 +326,93 @@ docker compose up -d # 启动 #### 升级 Gitea -Gitea 以 Docker 容器运行,升级 = 拉取新镜像 + 重启容器。数据库结构变更会在启动时自动迁移。 +使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。 ```bash cd /opt/gitea -# 1. 备份(必须!) -bash backup.sh - -# 2. 查看当前版本 -curl -s http://127.0.0.1:3000/api/v1/version - -# 3. 修改目标版本(编辑 .env 中的 GITEA_IMAGE) -# ● 查看最新版本号: https://github.com/go-gitea/gitea/releases -# ● 或访问: https://hub.docker.com/r/gitea/gitea/tags -vi .env -# 修改: GITEA_IMAGE=gitea/gitea:1.25.5 ← 替换为目标版本号 - -# 4. 拉取新镜像 -docker compose pull server - -# 5. 重启(自动执行数据库迁移) -docker compose up -d server - -# 6. 检查日志确认启动成功 -docker compose logs -f server -# 看到 "Starting new Web server: tcp:0.0.0.0:3000" 表示成功 -# Ctrl+C 退出日志 - -# 7. 验证新版本 -curl -s http://127.0.0.1:3000/api/v1/version +bash upgrade.sh --check # 先看看会发生什么,不做任何改动 +bash upgrade.sh # 正式升级到 GitHub 最新 release ``` -**注意事项:** -- 务必查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes -- 不支持版本降级,升级前务必备份 -- 跨多个大版本建议逐版本升级(如 1.21 → 1.22 → 1.23) +脚本做的事: + +| 步骤 | 内容 | 失败时 | +|------|------|--------| +| 1 | 预检:root、依赖命令、MySQL 连通性、数据表数量、磁盘空间 | 直接退出,服务零影响 | +| 2 | 拉取目标镜像 | 直接退出,服务零影响 | +| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — | +| 4 | 停止 Gitea 容器(**MySQL 保持运行**) | 自动拉起原版本 | +| 5 | mysqldump + 打包数据目录 + 校验备份完整性 | 自动拉起原版本 | +| 6 | 切换镜像启动,Gitea 自动执行数据库迁移 | 自动回滚 | +| 7 | 校验镜像 ID / 运行版本 / 接口 / 表行数 / 迁移版本 | 自动回滚 | + +常用参数: + +```bash +bash upgrade.sh --check # 只检查,零改动 +bash upgrade.sh --version 1.26.4 # 升级到指定版本 +bash upgrade.sh --full-backup # 连仓库和 LFS 一起备份(小实例推荐) +bash upgrade.sh --timeout 600 # 大库迁移慢,放宽就绪超时(默认 300 秒) +bash upgrade.sh --doctor # 升级后跑一次 gitea doctor 一致性检查 +bash upgrade.sh --yes # 跳过交互确认(自动化场景) +bash upgrade.sh --rollback # 回滚到上次升级前的状态 +``` + +**备份范围说明** + +默认只打包会被升级影响的部分:`gitea/conf`(app.ini)、`gitea/jwt`(OAuth2 签名密钥)、 +`ssh`(SSH host key)、`gitea/indexers`、`gitea/queues`、头像目录。 + +仓库(`git/repositories`)、LFS、软件包**默认不打包** —— 它们是内容寻址的追加式存储, +版本迁移只改数据库结构,不会改写这些文件。上百 GB 的实例每次升级都全量打包并不现实。 +数据库则**每次都完整 mysqldump**,因为那才是升级真正会动的东西。 + +小实例想要更保险,加 `--full-backup` 连仓库一起打包。 + +**回滚** + +```bash +bash upgrade.sh --rollback +``` + +读取 `.upgrade-state` 里记录的备份与镜像锚点,**回灌 SQL 备份** + 恢复配置目录 + 切回旧镜像。 + +> 回滚会把数据库整体退回到升级前的状态,升级之后新产生的 issue、PR、评论、仓库记录都会丢失。 +> 数据目录用的是合并解压,**不会删除任何仓库或 LFS 文件**。 + +#### 为什么不要手动升级 + +**一、跨小版本升级不可逆,只切镜像回滚是无效的** + +按 [Gitea 官方文档](https://docs.gitea.com/installation/upgrade-from-gitea/),只有 +`a.b.x → a.b.y`(补丁级)才保证数据库结构不变、可以来回切。跨小版本(如 1.25 → 1.27) +新版会在启动时改写数据库结构,之后旧版二进制看到新库会直接拒绝启动: + +``` +Your database (migration version: 286) is for a newer Gitea, +newer database for this old Gitea release (280). +Gitea will exit to keep your database safe and unchanged. +``` + +所以想退回旧版本,**唯一正确的办法是回灌升级前的 SQL 备份**。手动改 `version` 表是官方 +明确警告会丢数据的操作。`upgrade.sh` 的回滚就是按这个路径做的,并且会拒绝执行跨小版本降级。 + +**二、`docker compose up -d` 不会因为镜像变了就重建容器** + +容器处于 stopped 状态时,`up -d` 只是把原容器重新 start,结果是「改了配置但还跑着旧镜像」。 +必须 `--force-recreate`。脚本在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」, +不匹配直接回滚。 + +**三、`.env` 里的变量已经被 export 到当前 shell** + +部署脚本用 `set -a; source .env` 导出过 `GITEA_IMAGE`,而 docker compose 对环境变量的 +优先级高于 `.env` 文件 —— 只改文件不改环境变量,compose 仍会读到旧镜像。 + +**其它注意事项:** +- 升级前查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes +- 自定义模板与新版不兼容会导致 5xx 或页面错乱,脚本的首页检查能发现这类问题 +- MySQL 容器不在 `upgrade.sh` 的升级范围内,需要单独处理(见下) #### 升级 MySQL @@ -749,6 +800,8 @@ docker compose exec server gitea admin user change-password -u 管理员用户 ├── .env # 运行时配置(自动生成) ├── deploy.sh # 全新服务器一键部署脚本 ├── backup.sh # MySQL + 数据备份脚本 +├── upgrade.sh # 安全升级脚本(备份 → 校验 → 自动回滚) +├── .upgrade-state # 升级回滚锚点(upgrade.sh 自动生成) ├── uninstall.sh # 完全卸载脚本 ├── .gitignore ├── README.md diff --git a/gitea/upgrade.sh b/gitea/upgrade.sh new file mode 100644 index 0000000..f4a6bc5 --- /dev/null +++ b/gitea/upgrade.sh @@ -0,0 +1,1090 @@ +#!/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 指定目标版本(如 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" </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 "$@"