#!/bin/bash # 统一备份脚本 — 支持远程模式(从本地 Mac 执行)和本地模式(在服务器上直接执行) # 用法:bash backup.sh [--local] [--db-only|--full] [站点名...] set -e TIMESTAMP=$(date +%Y%m%d_%H%M%S) LOCAL_DIR="/Users/niuniu/programs/docker/db-backups" REMOTE_BASE="/root/docker" CLOUD_DIR="/root/docker/db-backups" MODE="remote" log() { echo "[$(date '+%H:%M:%S')] $1"; } warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; } # 参数解析(提取 --local / --db-only / --full,其余为站点名) ARGS=(); ACTION="default" for arg in "$@"; do case "$arg" in --local) MODE="local" ;; --db-only|--full) ACTION="${arg#--}" ;; --help|-h) ACTION="help" ;; *) ARGS+=("$arg") ;; esac done # 远程/本地适配 runcmd() { [[ "$MODE" == "local" ]] && bash -c "$*" || ssh txjp "$*"; } fetch() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "txjp:$1" "$2"; } # === 自动发现含数据库的站点 === PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" DB_SITES="" for dir in "$PROJECT_ROOT"/*-ai/; do s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue [ -f "$dir/docker-compose.yml" ] || continue grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue s="${s%-ai}"; [[ "$s" == "oa" ]] && continue DB_SITES="$DB_SITES $s" done container_for() { [[ "$1" == "ldap" ]] && echo "lldap" || echo "${1}-ai"; } # === 模式 1:站点完整备份(默认)=== backup_site() { local site="$1" backup_name="${site}-${TIMESTAMP}.tar.gz" log "备份 ${site}..." runcmd "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data" case "$site" in assets) runcmd "docker exec assets-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/assets.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true runcmd "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/" runcmd "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true" runcmd "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" ;; issue) runcmd "docker exec issue-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/issue.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true runcmd "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/" runcmd "cp -a /root/docker/issue-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true" runcmd "cp -a /root/docker/issue-ai/data/reports /tmp/backup-${site}/data/ 2>/dev/null || true" runcmd "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" ;; ldap) runcmd "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true runcmd "docker cp lldap:/data/users.db /tmp/backup-${site}/data/" runcmd "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" ;; gitea) runcmd "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/" runcmd "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true" runcmd "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" ;; oa) runcmd "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" ;; monitor) runcmd "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true runcmd "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/" runcmd "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" ;; *) warn "${site}: 无专用备份逻辑,仅备份配置文件" runcmd "cp /root/docker/${site}-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/${site}-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" ;; esac runcmd "cd /tmp && tar czf ${backup_name} backup-${site}/" mkdir -p "${LOCAL_DIR}" fetch "/tmp/${backup_name}" "${LOCAL_DIR}/" runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/" runcmd "rm -rf /tmp/backup-${site} /tmp/${backup_name}" log "${site} 备份完成: ${backup_name}" } backup_shared() { local backup_name="shared-${TIMESTAMP}.tar.gz" log "备份共享资源..." runcmd "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared" runcmd "mkdir -p /tmp/backup-shared/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/backup-shared/nginx/ 2>/dev/null || true" runcmd "mkdir -p /tmp/backup-shared/ssl && cp -a /etc/letsencrypt/live /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/backup-shared/ssl/ 2>/dev/null || true" runcmd "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true" runcmd "cd /tmp && tar czf ${backup_name} backup-shared/" mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${backup_name}" "${LOCAL_DIR}/" runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/" runcmd "rm -rf /tmp/backup-shared /tmp/${backup_name}" log "共享资源备份完成: ${backup_name}" } # === 模式 2:仅数据库备份 === backup_db_only() { log "开始数据库备份..." for s in $DB_SITES; do c=$(container_for "$s") [[ "$s" == "ldap" ]] && runcmd "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & [[ "$s" != "ldap" ]] && runcmd "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & done; wait runcmd "mkdir -p /tmp/db-backup" for s in $DB_SITES; do c=$(container_for "$s") dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db" log "备份 ${s}..." runcmd "docker cp ${c}:${dbpath} /tmp/db-backup/${s}-${TIMESTAMP}.db" done mkdir -p "${LOCAL_DIR}"; runcmd "mkdir -p ${CLOUD_DIR}" for s in $DB_SITES; do runcmd "cp /tmp/db-backup/${s}-${TIMESTAMP}.db ${CLOUD_DIR}/" fetch "/tmp/db-backup/${s}-${TIMESTAMP}.db" "${LOCAL_DIR}/" f="${LOCAL_DIR}/${s}-${TIMESTAMP}.db" sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1 || warn "${s} 备份文件无效" done runcmd "rm -rf /tmp/db-backup" log "数据库备份完成" } # === 模式 3:灾难恢复完整备份 === backup_full() { log "开始完整灾难恢复备份..." for s in $DB_SITES; do c=$(container_for "$s") [[ "$s" == "ldap" ]] && runcmd "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true [[ "$s" != "ldap" ]] && runcmd "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & done; wait runcmd "mkdir -p /tmp/full-backup/databases" for s in $DB_SITES; do c=$(container_for "$s") dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db" runcmd "docker cp ${c}:${dbpath} /tmp/full-backup/databases/${s}.db" done runcmd "mkdir -p /tmp/full-backup/gitea && cp -a /root/docker/gitea-ai/data/git /tmp/full-backup/gitea/ && cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/full-backup/gitea/ 2>/dev/null || true" runcmd "mkdir -p /tmp/full-backup/uploads && cp -a /root/docker/assets-ai/data/uploads /tmp/full-backup/uploads/assets-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/uploads /tmp/full-backup/uploads/issue-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/reports /tmp/full-backup/uploads/issue-reports 2>/dev/null || true" runcmd "mkdir -p /tmp/full-backup/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/full-backup/nginx/ 2>/dev/null || true" runcmd "mkdir -p /tmp/full-backup/ssl && cp -a /etc/letsencrypt/live /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/full-backup/ssl/ 2>/dev/null || true" local name="full-backup-${TIMESTAMP}" runcmd "cd /tmp && tar czf ${name}.tar.gz full-backup/" mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${name}.tar.gz" "${LOCAL_DIR}/" runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${name}.tar.gz ${CLOUD_DIR}/" runcmd "rm -rf /tmp/full-backup /tmp/${name}.tar.gz" log "完整备份完成: ${name}.tar.gz" } # === 主流程 === case "$ACTION" in help) echo "用法: bash backup.sh [--local] [--db-only|--full] [站点名...]" echo " --local 本地模式(直接在服务器上运行)" echo " 无参数 站点完整备份(全部含 DB 站点)" echo " --db-only 仅数据库备份" echo " --full 灾难恢复完整备份" echo " 站点名... 备份指定站点" ;; db-only) backup_db_only ;; full) backup_full ;; *) TARGETS="${ARGS[*]:-all}" log "站点完整备份: ${TARGETS} (模式: ${MODE})" if [[ "$TARGETS" == "all" ]]; then for site in $DB_SITES oa; do backup_site "$site"; done backup_shared else for site in $TARGETS; do backup_site "$site"; done fi ;; esac echo ""; log "备份完成!" ls -lh ${LOCAL_DIR}/*-${TIMESTAMP}* 2>/dev/null