diff --git a/gitea/backup.sh b/gitea/backup.sh index cf2a00c..0188066 100755 --- a/gitea/backup.sh +++ b/gitea/backup.sh @@ -1,68 +1,286 @@ #!/usr/bin/env bash -set -euo pipefail +# -E 让 ERR trap 在函数内部同样生效(冷备份模式下必须靠它把服务拉回来) +set -Eeuo pipefail # ============================================ # Gitea 备份脚本 (MySQL 版) -# 备份内容:MySQL 数据库 + Gitea 数据(含 LFS) + 配置 +# +# 备份内容:MySQL 数据库 + Gitea 数据(含仓库/LFS/密钥) + 部署配置 +# +# 用法: +# bash backup.sh 热备份(服务不停机,默认) +# bash backup.sh --cold 冷备份(停 Gitea 取快照,最一致) +# bash backup.sh --keep 60 保留天数(默认 30) +# bash backup.sh --verify-last 只校验最近一次备份,不产生新备份 +# # 定时执行: crontab -e → 0 3 * * * /opt/gitea/backup.sh # ============================================ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -# 加载配置 -if [ ! -f .env ]; then - echo "[ERROR] .env 文件不存在" >&2 - exit 1 -fi +DATE=$(date +%Y%m%d_%H%M%S) +KEEP_DAYS=30 +MIN_KEEP_SETS=3 # 无论多旧,至少保留这么多份完整备份 +MODE="hot" # hot | cold | verify +SVC_APP="server" +SVC_DB="db" +DB_NAME="gitea" +STOPPED=0 + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } +warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" >&2; } +die() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; exit 1; } + +usage() { + sed -n '5,18p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +while [ $# -gt 0 ]; do + case "$1" in + --cold) MODE="cold" ;; + --verify-last) MODE="verify" ;; + --keep) shift; [ $# -gt 0 ] || die "--keep 需要一个参数" + [[ "$1" =~ ^[0-9]+$ ]] || die "--keep 必须是整数天" + KEEP_DAYS="$1" ;; + -h|--help) usage ;; + *) die "未知参数: $1(--help 查看用法)" ;; + esac + shift +done + +# ===== 加载配置 ===== +[ -f .env ] || die ".env 文件不存在" +if grep -q $'\r' .env 2>/dev/null; then sed -i 's/\r$//' .env; fi set -a -sed -i 's/\r$//' .env +# shellcheck disable=SC1091 source .env set +a BACKUP_DIR="${BACKUP_DIR:-/var/backups/gitea}" GITEA_DATA_DIR="${GITEA_DATA_DIR:-/var/lib/gitea}" -DATE=$(date +%Y%m%d_%H%M%S) -KEEP_DAYS=30 +[ -n "${DB_ROOT_PASSWORD:-}" ] || die ".env 中缺少 DB_ROOT_PASSWORD" -log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } +# ===== 前置检查 ===== +[ "$(id -u)" -eq 0 ] || die "需要 root 权限运行" +for c in docker tar gzip find awk; do + command -v "$c" >/dev/null 2>&1 || die "缺少命令: $c" +done +docker compose version >/dev/null 2>&1 || die "docker compose 不可用" +[ -d "$GITEA_DATA_DIR" ] || die "数据目录不存在: $GITEA_DATA_DIR" mkdir -p "$BACKUP_DIR" +chmod 700 "$BACKUP_DIR" -# 1. 备份 MySQL 数据库 +# 防止 cron 叠加执行:上一次还没跑完就不再起新的 +exec 9>"$BACKUP_DIR/.backup.lock" +flock -n 9 || die "已有另一个备份进程在运行,本次退出" + +# MySQL 容器用 MYSQL_PWD 传密码,避免密码出现在 ps 的命令行里 +db_exec() { docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SVC_DB" "$@"; } + +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" ] +} + +# ============================================================= +# 校验:备份产出后必须自证可用,否则等于没备 +# ============================================================= +verify_db_dump() { + local f="$1" summary tables completed + [ -f "$f" ] || { warn "SQL 备份不存在: $f"; return 1; } + + gzip -t "$f" 2>/dev/null || { warn "SQL 备份 gzip 校验失败: $f"; return 1; } + + # 一次解压同时数建表语句和找结束标记。用 awk 读完整个流,不能写成 + # `gunzip -c | grep -q` —— grep 命中即退出会让 gunzip 收到 SIGPIPE, + # 在 pipefail 下把好备份误判成坏的。 + summary="$(gunzip -c "$f" | awk ' + /^CREATE TABLE / { n++ } + /-- Dump completed/ { d = 1 } + END { print n+0, d+0 } + ')" || { warn "SQL 备份无法完整解压: $f"; return 1; } + + tables="${summary%% *}"; completed="${summary##* }" + # "Dump completed" 只有 mysqldump 正常收尾才会写;被 OOM / 断连截断的没有 + [ "$completed" = "1" ] || { warn "SQL 备份缺少结束标记,导出被截断: $f"; return 1; } + [ "$tables" -ge 10 ] || { warn "SQL 备份只有 $tables 条 CREATE TABLE: $f"; return 1; } + + log " SQL 备份校验通过(${tables} 张表,含结束标记)" + return 0 +} + +verify_data_tar() { + local f="$1" listing repos + [ -f "$f" ] || { warn "数据备份不存在: $f"; return 1; } + + gzip -t "$f" 2>/dev/null || { warn "数据备份 gzip 校验失败: $f"; return 1; } + listing="$(tar tzf "$f")" || { warn "数据备份无法列出内容: $f"; return 1; } + + grep -q 'gitea/conf/app\.ini$' <<<"$listing" || { warn "数据备份中没有 app.ini"; return 1; } + grep -q '/ssh/' <<<"$listing" || warn "数据备份中没有 SSH host key 目录" + grep -q '/jwt/' <<<"$listing" || warn "数据备份中没有 JWT 密钥目录" + + repos=$(grep -c 'repositories/[^/]*/[^/]*\.git/$' <<<"$listing" || true) + log " 数据备份校验通过($(wc -l <<<"$listing") 个条目,${repos} 个仓库)" + return 0 +} + +verify_set() { + local ts="$1" ok=0 + verify_db_dump "${BACKUP_DIR}/db_${ts}.sql.gz" || ok=1 + verify_data_tar "${BACKUP_DIR}/gitea_data_${ts}.tar.gz" || ok=1 + [ -f "${BACKUP_DIR}/config_${ts}.tar.gz" ] || { warn "配置备份缺失"; ok=1; } + return $ok +} + +# ===== --verify-last:只校验,不产生新备份 ===== +if [ "$MODE" = "verify" ]; then + last=$(find "$BACKUP_DIR" -maxdepth 1 -name 'db_*.sql.gz' -printf '%f\n' 2>/dev/null \ + | sed 's/^db_//; s/\.sql\.gz$//' | sort | tail -1) + [ -n "$last" ] || die "$BACKUP_DIR 中没有找到任何备份" + log "校验最近一次备份: $last" + if verify_set "$last"; then log "===== 备份 $last 校验通过 ====="; exit 0 + else die "备份 $last 校验未通过"; fi +fi + +# ============================================================= +# 冷备份:停 Gitea 取一致快照,MySQL 全程保持运行 +# ============================================================= +restart_app_if_stopped() { + [ "$STOPPED" -eq 1 ] || return 0 + STOPPED=0 + log "正在恢复 Gitea 服务..." + docker compose up -d "$SVC_APP" >/dev/null 2>&1 || \ + warn "Gitea 恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d" +} +trap 'restart_app_if_stopped' EXIT + +db_running || die "MySQL 容器未运行,无法备份数据库: docker compose up -d $SVC_DB" + +# ===== 磁盘空间预检 ===== +data_kb=$(du -sk "$GITEA_DATA_DIR" 2>/dev/null | cut -f1 || echo 0) +[[ "$data_kb" =~ ^[0-9]+$ ]] || data_kb=0 +avail_kb=$(df -Pk "$BACKUP_DIR" | awk 'NR==2{print $4}') +need_kb=$(( data_kb * 6 / 5 + 512000 )) +if [ -n "$avail_kb" ] && [ "$avail_kb" -lt "$need_kb" ]; then + die "磁盘空间不足: 可用 $((avail_kb/1024))MB,预计需要 $((need_kb/1024))MB" +fi + +log "===== 开始备份(模式: $MODE)=====" + +if [ "$MODE" = "cold" ]; then + log "停止 Gitea 以取得一致快照(MySQL 不停)..." + docker compose stop "$SVC_APP" >/dev/null 2>&1 + STOPPED=1 + log "Gitea 已停止" +fi + +# ============================================================= +# 1. 数据库 +# ============================================================= +# 顺序很重要:先导数据库、后打包文件。 +# 热备份下两者必然是两个时刻的快照,若中间发生 push: +# 先库后文件 → 文件里多出库中没有的对象(孤儿对象,无害) +# 先文件后库 → 库里记录了文件中不存在的提交(引用悬空,有害) +# 所以先导库是两害相权取其轻。要完全一致请用 --cold。 log "正在备份 MySQL 数据库..." -docker compose exec -T db mysqldump \ - -u root -p"${DB_ROOT_PASSWORD}" \ - --single-transaction \ - --routines \ - --triggers \ - --databases gitea \ - | gzip > "${BACKUP_DIR}/db_${DATE}.sql.gz" -log "数据库备份完成: db_${DATE}.sql.gz ($(du -h "${BACKUP_DIR}/db_${DATE}.sql.gz" | cut -f1))" +DB_FILE="${BACKUP_DIR}/db_${DATE}.sql.gz" -# 2. 备份 Gitea 数据(仓库、LFS、附件、头像等) -log "正在备份 Gitea 数据目录(含 LFS)..." -tar czf "${BACKUP_DIR}/gitea_data_${DATE}.tar.gz" \ +dump_opts=(-u root --single-transaction --routines --triggers + --hex-blob --default-character-set=utf8mb4 --databases "$DB_NAME") +# 无 GTID 的实例上 mysqldump 会写 SET @@GLOBAL.GTID_PURGED,回灌时报错。 +# 先收进变量再匹配,避免 grep -q 的 SIGPIPE 让探测结果反转。 +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 + +db_exec mysqldump "${dump_opts[@]}" | gzip > "$DB_FILE" +chmod 600 "$DB_FILE" +verify_db_dump "$DB_FILE" || die "数据库备份未通过校验,已中止(不清理旧备份)" +log "数据库备份完成: db_${DATE}.sql.gz ($(du -h "$DB_FILE" | cut -f1))" + +# ============================================================= +# 2. Gitea 数据目录(仓库、LFS、附件、密钥) +# ============================================================= +log "正在备份 Gitea 数据目录(含仓库与 LFS)..." +DATA_FILE="${BACKUP_DIR}/gitea_data_${DATE}.tar.gz" +tar czf "$DATA_FILE" \ -C "$(dirname "$GITEA_DATA_DIR")" \ "$(basename "$GITEA_DATA_DIR")" -log "数据目录备份完成: gitea_data_${DATE}.tar.gz ($(du -h "${BACKUP_DIR}/gitea_data_${DATE}.tar.gz" | cut -f1))" +# 包里有 SSH host key 和 OAuth2 JWT 签名密钥,权限必须收紧 +chmod 600 "$DATA_FILE" +verify_data_tar "$DATA_FILE" || die "数据目录备份未通过校验,已中止(不清理旧备份)" +log "数据目录备份完成: gitea_data_${DATE}.tar.gz ($(du -h "$DATA_FILE" | cut -f1))" -# 3. 备份配置文件 +# ============================================================= +# 3. 部署配置 +# ============================================================= log "正在备份配置文件..." -tar czf "${BACKUP_DIR}/config_${DATE}.tar.gz" \ - -C "$SCRIPT_DIR" \ - .env docker-compose.yml nginx/ +CONF_FILE="${BACKUP_DIR}/config_${DATE}.tar.gz" +conf_items=(.env docker-compose.yml) +[ -d nginx ] && conf_items+=(nginx) +tar czf "$CONF_FILE" -C "$SCRIPT_DIR" "${conf_items[@]}" +chmod 600 "$CONF_FILE" # 含 .env 里的数据库密码 +gzip -t "$CONF_FILE" 2>/dev/null || die "配置备份 gzip 校验失败" log "配置备份完成: config_${DATE}.tar.gz" -# 4. 清理过期备份 -log "清理 ${KEEP_DAYS} 天前的备份..." -deleted=$(find "$BACKUP_DIR" -type f -mtime +${KEEP_DAYS} -print -delete | wc -l) -log "已清理 ${deleted} 个过期文件" +# 冷备份到此结束,尽早把服务放回去,后面的清理不该占用停机时间 +restart_app_if_stopped -# 5. 输出备份摘要 +# ============================================================= +# 4. 清理过期备份 +# ============================================================= +# 三条安全约束,缺一不可: +# a) 只删本脚本自己产出的三种文件名,绝不碰 upgrade.sh 的 pre-upgrade-* +# 回滚锚点,也不碰别人放进这个目录的东西 +# b) 按「备份集」整组判断,不按单个文件,避免删出半套残废备份 +# c) 无论多旧,至少保留 MIN_KEEP_SETS 份,防止长期备份失败后 +# 把最后一份能用的也清掉 +log "清理 ${KEEP_DAYS} 天前的备份(至少保留最近 ${MIN_KEEP_SETS} 份)..." + +mapfile -t all_sets < <( + find "$BACKUP_DIR" -maxdepth 1 -name 'db_*.sql.gz' -printf '%f\n' 2>/dev/null \ + | sed 's/^db_//; s/\.sql\.gz$//' | sort +) +total=${#all_sets[@]} +deleted=0 + +if [ "$total" -gt "$MIN_KEEP_SETS" ]; then + # 只考察最老的那些,末尾 MIN_KEEP_SETS 份无条件保留 + candidates=$(( total - MIN_KEEP_SETS )) + for (( i = 0; i < candidates; i++ )); do + ts="${all_sets[$i]}" + f="${BACKUP_DIR}/db_${ts}.sql.gz" + # 用 mtime 判断是否过期;-mmin 比 -mtime 精度更好 + if [ -n "$(find "$f" -maxdepth 0 -mmin +$(( KEEP_DAYS * 1440 )) 2>/dev/null)" ]; then + rm -f "${BACKUP_DIR}/db_${ts}.sql.gz" \ + "${BACKUP_DIR}/gitea_data_${ts}.tar.gz" \ + "${BACKUP_DIR}/config_${ts}.tar.gz" + log " 已删除过期备份集: $ts" + deleted=$(( deleted + 1 )) + fi + done +fi +log "已清理 ${deleted} 份过期备份集,当前保留 $(( total - deleted )) 份" + +# ============================================================= +# 5. 摘要 +# ============================================================= echo "" log "===== 备份完成 =====" -log "备份目录: ${BACKUP_DIR}/" +log "备份集: ${DATE}(模式: ${MODE})" ls -lh "${BACKUP_DIR}/"*"${DATE}"* 2>/dev/null || true echo "" -log "总备份空间占用: $(du -sh "${BACKUP_DIR}" | cut -f1)" +log "备份目录: ${BACKUP_DIR}/" +log "总占用: $(du -sh "$BACKUP_DIR" | cut -f1)" +if [ "$MODE" = "hot" ]; then + log "提示: 这是热备份,数据库与仓库文件取自不同时刻。" + log " 需要完全一致的快照请用: bash backup.sh --cold" +fi +log "校验最近备份: bash backup.sh --verify-last" diff --git a/gitea/upgrade.sh b/gitea/upgrade.sh index f4a6bc5..9623f93 100644 --- a/gitea/upgrade.sh +++ b/gitea/upgrade.sh @@ -160,6 +160,9 @@ load_env() { BACKUP_BASE="${BACKUP_DIR:-/var/backups/gitea}" DOMAIN="${GITEA_DOMAIN:-}" CURRENT_IMAGE="${GITEA_IMAGE:-gitea/gitea:1.25}" + # 健康检查探测的本地端口。默认 3000 与生产一致;隔离演练环境 + # 可在 .env 里设 GITEA_HTTP_PORT,避免探到别的实例上去。 + HTTP_PORT="${GITEA_HTTP_PORT:-3000}" if [ -z "${DB_ROOT_PASSWORD:-}" ]; then error ".env 中缺少 DB_ROOT_PASSWORD,无法备份数据库" @@ -177,6 +180,17 @@ db_query() { db_exec mysql -u root -N -B -e "$1" 2>/dev/null | tr -d '\r' } +# 输入 KB,输出人类可读。整数除法会把 780KB 显示成 "0MB", +# 让人误以为没有数据要备份,所以小于 1MB 时改用 KB 显示。 +fmt_size() { + local kb="${1:-0}" + [[ "$kb" =~ ^[0-9]+$ ]] || { echo "未知"; return; } + if [ "$kb" -lt 1024 ]; then echo "${kb}KB" + elif [ "$kb" -lt 1048576 ]; then echo "$((kb/1024))MB" + else echo "$((kb/1048576))GB" + fi +} + db_running() { local cid cid=$(docker compose ps -a -q "$SVC_DB" 2>/dev/null | head -1) @@ -250,7 +264,7 @@ preflight() { fi log "root 权限、依赖命令、数据目录、磁盘空间 均正常" - info "数据库 $((db_kb/1024))MB,待备份数据 $((data_kb/1024))MB,可用空间 $((avail_kb/1024))MB" + info "数据库 $(fmt_size "$db_kb"),待备份数据 $(fmt_size "$data_kb"),可用空间 $(fmt_size "$avail_kb")" } # ============================================================= @@ -346,7 +360,7 @@ detect_current_version() { 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 \ + v=$(curl -sf --max-time 10 "http://127.0.0.1:${HTTP_PORT}/api/v1/version" 2>/dev/null \ | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \ | sed 's/.*"\([^"]*\)"$/\1/' | cut -d+ -f1 || true) fi @@ -473,6 +487,8 @@ cold_backup() { printf ' %s\n' "${BACKUP_PATHS[@]}" tar czf "$DATA_BACKUP" -C "$(dirname "$DATA_DIR")" "${BACKUP_PATHS[@]}" + # 包里有 SSH host key 和 OAuth2 JWT 签名密钥,权限必须收紧 + chmod 600 "$DATA_BACKUP" verify_data_backup "$DATA_BACKUP" log "数据备份完成: $(du -h "$DATA_BACKUP" | cut -f1)" @@ -601,7 +617,7 @@ wait_healthy() { 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)" + code="$(http_code "http://127.0.0.1:${HTTP_PORT}/api/v1/version" 3)" if [ "$code" != "000" ]; then log "Gitea 已就绪(${i}s,HTTP ${code})" return 0 @@ -668,7 +684,7 @@ verify_endpoints() { step "接口验证" local failed=0 code - code="$(http_code "http://127.0.0.1:3000/api/v1/version" 10)" + code="$(http_code "http://127.0.0.1:${HTTP_PORT}/api/v1/version" 10)" case "$code" in 200) log "/api/v1/version → 200" ;; 401|403) log "/api/v1/version → ${code}(已开启强制登录,属预期)" ;; @@ -677,7 +693,7 @@ verify_endpoints() { esac # 首页:模板损坏(自定义模板与新版不兼容)会在这里暴露成 5xx - code="$(http_code "http://127.0.0.1:3000/" 10)" + code="$(http_code "http://127.0.0.1:${HTTP_PORT}/" 10)" if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then error "首页 → ${code}(模板渲染失败?)"; failed=1 else @@ -727,6 +743,18 @@ verify_data_integrity() { error "数据库迁移版本倒退: $MIGRATION_BEFORE → $migration_after" return 1 fi + + # 跨小版本升级必然伴随数据库结构变更,迁移版本理应前进。它没动就说明 + # 新版本压根没对这个库执行迁移 —— 可能是健康检查探到了别的实例、 + # 容器连到了另一个数据库,或验证跑在迁移完成之前。 + # 这种情况下「报成功」远比「报失败」危险,必须拦下来。 + if [ -n "$CURRENT_VERSION" ] \ + && [ "$(ver_series "$CURRENT_VERSION")" != "$(ver_series "$TARGET_VERSION")" ] \ + && [ "$migration_after" -eq "$MIGRATION_BEFORE" ]; then + error "跨小版本升级 ${CURRENT_VERSION} → ${TARGET_VERSION},迁移版本却没有前进(仍为 ${MIGRATION_BEFORE})" + error "新版本似乎没有对这个数据库执行迁移,不能认定升级成功" + return 1 + fi fi # 逐表比对:行数只允许持平或增加,减少视为数据丢失 @@ -866,6 +894,26 @@ on_exit() { return 0 } +# 回滚后 .env 会被钉在 pre-upgrade-<时间戳> 这个「只存在于本机」的标签上。 +# 而升级成功时脚本又会建议 `docker image rm` 掉这类标签 —— 处在回滚状态的人 +# 照做(或例行清理旧镜像)就会把自己的部署搞挂。必须把这层依赖讲明白。 +warn_rollback_pinning() { + local restored_ver="${1:-}" + echo "" + warn "══════ 重要:.env 现在钉在回滚镜像上 ══════" + warn " GITEA_IMAGE=${ROLLBACK_TAG}" + warn "该标签只存在于本机,删掉它服务将无法启动。" + warn "清理 docker 镜像时务必跳过它!" + if [ -n "$restored_ver" ]; then + warn "" + warn "确认服务稳定后,可以这样回到常规版本号标签(无需联网重新拉取):" + warn " docker tag ${ROLLBACK_TAG} ${IMAGE_REPO}:${restored_ver}" + warn " sed -i 's|^GITEA_IMAGE=.*|GITEA_IMAGE=${IMAGE_REPO}:${restored_ver}|' ${SCRIPT_DIR}/.env" + warn " cd ${SCRIPT_DIR} && docker compose up -d --force-recreate --no-deps ${SVC_APP}" + fi + warn "═══════════════════════════════════════════" +} + do_rollback() { trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归 SAFETY_STAGE="done" @@ -899,6 +947,7 @@ do_rollback() { warn "升级失败已回滚。备份保留在:" warn " 数据库: $DB_BACKUP" warn " 数据: $DATA_BACKUP" + warn_rollback_pinning "${CURRENT_VERSION:-}" exit 1 } @@ -945,8 +994,10 @@ manual_rollback() { if wait_healthy 180; then log "回滚完成" verify_endpoints || warn "接口验证有告警,请检查日志" + warn_rollback_pinning "${FROM_VERSION:-}" else error "服务未就绪,请检查: docker compose logs --tail 100 $SVC_APP" + warn_rollback_pinning "${FROM_VERSION:-}" exit 1 fi } @@ -1084,7 +1135,9 @@ main() { echo "" warn "请登录 Web 端确认仓库、议题、LFS 都正常后,再考虑清理回滚镜像:" warn " docker image rm $ROLLBACK_TAG" - warn "升级前的备份请至少保留到确认无误之后,不要急着删。" + warn "但要清楚代价:删掉之后 'bash upgrade.sh --rollback' 会因找不到镜像而失效," + warn "届时只能靠 SQL 备份手工恢复。建议先跑一段时间确认无误再删。" + warn "升级前的备份同理,不要急着删。" } main "$@"