MongoDB 安全数据替换脚本 (执行顺序:备份→校验→确认→清空→还原指定数据→失败回滚到备份)

#!/bin/bash

# ==============================================
# MongoDB 安全数据替换脚本 (执行顺序:备份→校验→确认→清空→还原指定数据→失败回滚到备份)
# 依赖命令: mongoexport, mongorestore, mongosh/mongo
# ==============================================

# Configuration parameters (配置参数)
DEFAULT_HOST="localhost"
DEFAULT_PORT="27017"
DEFAULT_AUTH_SOURCE="admin"
BACKUP_DIR="./mongo_backups"
LOG_FILE="./mongo_restore_$(date +%Y%m%d_%H%M%S).log"

# Color definitions (颜色定义)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color (无颜色)

# Global variable to store the backup file path (全局变量,用于存储备份文件路径)
BACKUP_FILE=""

# Log messages (日志消息函数)
log() {
    local level=$1
    local message=$2
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[${timestamp}] [${level}] ${message}" >> "$LOG_FILE"
    case "$level" in
        "ERROR") echo -e "${RED}[ERROR]${NC} ${message}" >&2 ;;
        "WARN") echo -e "${YELLOW}[WARN]${NC} ${message}" ;;
        "SUCCESS") echo -e "${GREEN}[SUCCESS]${NC} ${message}" ;;
        *) echo "[INFO] ${message}" ;;
    esac
}

# Check for required commands (检查所需命令)
check_required_commands() {
    local missing=()
    
    # Check for mongosh or mongo (检查 mongosh 或 mongo)
    if ! command -v mongosh &> /dev/null && ! command -v mongo &> /dev/null; then
        missing+=("mongosh/mongo")
    fi
    
    # Check for other required commands (检查其他所需命令)
    for cmd in mongoexport mongorestore mongoimport; do
        if ! command -v "$cmd" &> /dev/null; then
            missing+=("$cmd")
        fi
    done
    
    if [ ${#missing[@]} -gt 0 ]; then
        log "ERROR" "缺少必要的命令: ${missing[*]}"
        log "ERROR" "请确保以下命令已安装并配置在您的系统 PATH 中:"
        log "ERROR" "1. mongoexport"
        log "ERROR" "2. mongorestore"
        log "ERROR" "3. mongoimport"
        log "ERROR" "4. mongosh 或 mongo"
        exit 1
    fi
}

# Find MongoDB client command (mongosh or mongo) (查找 MongoDB 客户端命令)
find_mongo_client() {
    if command -v mongosh &> /dev/null; then
        MONGO_CLIENT_CMD="mongosh"
    elif command -v mongo &> /dev/null; then
        MONGO_CLIENT_CMD="mongo"
    else
        log "ERROR" "严重错误: 未找到 MongoDB Shell 客户端"
        exit 1
    fi
    log "INFO" "正在使用 MongoDB 客户端: ${MONGO_CLIENT_CMD}"
}

# Validate file existence and readability (验证文件存在性和可读性)
validate_file() {
    local file=$1
    local description=$2
    
    if [ ! -e "$file" ]; then
        log "ERROR" "${description} 文件不存在: $file"
        return 1 # Indicate failure (指示失败)
    fi
    
    if [ ! -f "$file" ]; then
        log "ERROR" "${description} 路径不是一个文件: $file"
        return 1 # Indicate failure (指示失败)
    fi
    
    if [ ! -r "$file" ]; then
        log "ERROR" "无法读取 ${description} 文件: $file"
        return 1 # Indicate failure (指示失败)
    fi
    return 0 # Indicate success (指示成功)
}

# Display help information (显示帮助信息)
show_help() {
    echo -e "${YELLOW}MongoDB 安全数据替换脚本 - 执行顺序: 备份 → 校验 → 确认 → 清空 → 还原指定数据 → 失败回滚到备份${NC}"
    echo ""
    echo "用法:"
    echo "  $0 -d 数据库名 -c 集合名 [-f 导入文件] [选项]"
    echo ""
    echo "必填参数:"
    echo "  -d, --database      数据库名称"
    echo "  -c, --collection    集合名称"
    echo ""
    echo "可选参数 (如果未提供 -f,则必须使用 --clear-only 或 --no-clear):"
    echo "  -f, --file          要导入的数据文件路径 (必须是 JSON/BSON 格式)"
    echo ""
    echo "选项:"
    echo "  -h, --host          MongoDB 主机 (默认: ${DEFAULT_HOST})"
    echo "  -p, --port          MongoDB 端口 (默认: ${DEFAULT_PORT})"
    echo "  -u, --username      认证用户名"
    echo "  -w, --password      认证密码"
    echo "  -a, --auth-source   认证数据库 (默认: ${DEFAULT_AUTH_SOURCE})"
    echo "  -y, --assume-yes    跳过确认并直接执行 (危险操作!)"
    echo "  --clear-only        仅清空集合并备份,不导入新数据 (此模式下无需 -f)"
    echo "  --no-clear          跳过清空集合步骤 (导入时保留现有数据)"
    echo "  --help              显示此帮助信息"
    echo ""
    echo -e "${RED}注意: 此脚本已修改。所有操作现在都将强制进行备份。${NC}"
    echo -e "${RED}重要: 清空和导入操作只有在备份成功 并且 (如果提供了导入文件) 导入文件有效时才会继续。${NC}"
    echo -e "${RED}如果导入失败,将尝试自动回滚到备份数据。${NC}"
    echo "脚本需要 'mongoexport', 'mongorestore', 'mongoimport' 和 'mongosh' (或 'mongo') 命令在您的系统 PATH 中。"
    exit 0
}

# Initialize environment (初始化环境)
init_env() {
    # First, check for required commands (首先,检查所需命令)
    check_required_commands
    
    # Then, find the client (然后,查找客户端)
    find_mongo_client
    
    # Create backup directory (创建备份目录)
    mkdir -p "$BACKUP_DIR" || {
        log "ERROR" "创建备份目录失败: $BACKUP_DIR"
        exit 1
    }
    
    # Initialize log file (初始化日志文件)
    echo "=== MongoDB 安全替换脚本日志 ===" > "$LOG_FILE"
    echo "执行时间: $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
    echo "依赖检查:" >> "$LOG_FILE"
    echo "  mongosh/mongo: $(command -v ${MONGO_CLIENT_CMD})" >> "$LOG_FILE"
    echo "  mongoexport: $(command -v mongoexport)" >> "$LOG_FILE"
    echo "  mongorestore: $(command -v mongorestore)" >> "$LOG_FILE"
    echo "  mongoimport: $(command -v mongoimport)" >> "$LOG_FILE"
    echo "==============================" >> "$LOG_FILE"
}

# Parse command-line arguments (解析命令行参数)
parse_arguments() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -d|--database)
                DB_NAME="$2"
                shift 2
                ;;
            -c|--collection)
                COLLECTION_NAME="$2"
                shift 2
                ;;
            -f|--file)
                IMPORT_FILE="$2"
                shift 2
                ;;
            -h|--host)
                HOST="$2"
                shift 2
                ;;
            -p|--port)
                PORT="$2"
                if ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
                    log "ERROR" "端口号必须是数字"
                    exit 1
                fi
                shift 2
                ;;
            -u|--username)
                USERNAME="$2"
                shift 2
                ;;
            -w|--password)
                PASSWORD="$2"
                shift 2
                ;;
            -a|--auth-source)
                AUTH_SOURCE="$2"
                shift 2
                ;;
            -y|--assume-yes)
                ASSUME_YES=true
                shift
                ;;
            --clear-only)
                CLEAR_ONLY=true
                shift
                ;;
            --no-clear)
                NO_CLEAR=true
                shift
                ;;
            --help)
                show_help
                ;;
            *)
                log "ERROR" "未知参数: $1"
                show_help
                exit 1
                ;;
        esac
    done

    # Validate required parameters (验证必填参数)
    if [ -z "$DB_NAME" ] || [ -z "$COLLECTION_NAME" ]; then
        log "ERROR" "缺少必填参数: 数据库名或集合名!"
        show_help
        exit 1
    fi

    # Validate --clear-only and --no-clear are mutually exclusive (验证 --clear-only 和 --no-clear 互斥)
    if [ "$CLEAR_ONLY" = true ] && [ "$NO_CLEAR" = true ]; then
        log "ERROR" "不能同时使用 --clear-only 和 --no-clear 选项。"
        show_help
        exit 1
    fi

    # Validate import file (-f) usage logic (验证导入文件 (-f) 的使用逻辑)
    if [ -n "$IMPORT_FILE" ]; then
        if [ "$CLEAR_ONLY" = true ]; then
            log "ERROR" "不能同时指定导入文件 (-f) 和 --clear-only 选项。"
            show_help
            exit 1
        fi
        
        # Validate file extension (验证文件扩展名)
        local ext="${IMPORT_FILE##*.}"
        if [[ ! "$ext" =~ ^(json|bson)$ ]]; then
            log "ERROR" "不支持的导入文件格式: $ext (仅支持 .json 或 .bson)"
            exit 1
        fi
    else # No import file specified (未指定导入文件)
        if [ "$CLEAR_ONLY" != true ] && [ "$NO_CLEAR" != true ]; then
            log "ERROR" "请提供要导入的文件 (-f),或使用 --clear-only / --no-clear 选项。"
            log "ERROR" "注意: 所有操作都将强制进行备份。"
            show_help
            exit 1
        fi
    fi

    # If --no-clear is specified, an import file must also be specified (如果指定了 --no-clear,则必须同时指定导入文件)
    if [ "$NO_CLEAR" = true ] && [ -z "$IMPORT_FILE" ]; then
        log "ERROR" "使用 --no-clear 选项时,必须指定导入文件 (-f)。否则,操作无意义。"
        show_help
        exit 1
    fi
}

# Build MongoDB connection string and authentication options (构建 MongoDB 连接字符串和认证选项)
build_connection() {
    HOST="${HOST:-$DEFAULT_HOST}"
    PORT="${PORT:-$DEFAULT_PORT}"
    AUTH_SOURCE="${AUTH_SOURCE:-$DEFAULT_AUTH_SOURCE}"

    # Build URI connection string for mongosh/mongo shell (为 mongosh/mongo shell 构建 URI 连接字符串)
    MONGO_URI=""
    if [ -n "$USERNAME" ] && [ -n "$PASSWORD" ]; then
        # URL-encode special characters in password (对密码中的特殊字符进行 URL 编码)
        # Check if jq is available for URL encoding, otherwise fall back (检查 jq 是否可用,否则回退)
        if command -v jq &>/dev/null; then
            ENCODED_PASSWORD=$(printf '%s' "$PASSWORD" | jq -sRr @uri)
        else
            log "WARN" "未找到 jq 命令。密码可能未进行 URL 编码。请确保您的密码不包含特殊字符,以便直接在 URI 中使用。"
            ENCODED_PASSWORD="$PASSWORD"
        fi
        MONGO_URI="mongodb://${USERNAME}:${ENCODED_PASSWORD}@${HOST}:${PORT}/${DB_NAME}?authSource=${AUTH_SOURCE}"
    else
        MONGO_URI="mongodb://${HOST}:${PORT}/${DB_NAME}"
    fi

    # Build traditional command-line arguments for mongoexport/mongorestore (为 mongoexport/mongorestore 构建传统命令行参数)
    AUTH_STR=""
    if [ -n "$USERNAME" ] && [ -n "$PASSWORD" ]; then
        AUTH_STR="--username ${USERNAME} --password ${PASSWORD} --authenticationDatabase ${AUTH_SOURCE}"
    fi
    FULL_OPTIONS="--host ${HOST} --port ${PORT} ${AUTH_STR}"
}

# Backup existing data (备份现有数据)
# Returns 0 on successful (non-empty) backup, 1 otherwise (成功备份(非空)返回 0,否则返回 1)
backup_data() {
    local timestamp=$(date +"%Y%m%d_%H%M%S")
    BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${COLLECTION_NAME}_${timestamp}.json"
    
    log "INFO" "正在开始备份现有数据 -> ${BACKUP_FILE}"
    
    if ! mongoexport ${FULL_OPTIONS} \
        --db "${DB_NAME}" \
        --collection="${COLLECTION_NAME}" \
        --out="${BACKUP_FILE}" \
        --jsonArray >> "$LOG_FILE" 2>&1; then
        log "ERROR" "备份失败! 请检查 MongoDB 连接或权限。后续操作将被跳过。"
        return 1
    fi
    
    # Validate backup file (验证备份文件)
    if [ ! -s "$BACKUP_FILE" ]; then
        log "WARN" "备份文件为空 (可能是空集合)。这通常没问题,但请注意。"
        return 0 # Still consider it a successful backup of an empty state (仍然认为这是空状态的成功备份)
    else
        BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
        log "SUCCESS" "备份成功 (大小: ${BACKUP_SIZE})"
        return 0
    fi
}

# Confirm operation (delete or clear) (确认操作(删除或清空))
confirm_operation() {
    if [ "$ASSUME_YES" = true ]; then
        log "WARN" "已启用自动确认,跳过用户确认"
        return 0
    fi

    local BACKUP_STATUS="${GREEN}强制备份已完成${NC}" # Always backed up now (现在总是已备份)

    local CLEAR_STATUS="永久删除"
    if [ "$NO_CLEAR" = true ]; then
        CLEAR_STATUS="${YELLOW}不删除${NC} (保留现有数据)"
    elif [ "$CLEAR_ONLY" = true ]; then
        CLEAR_STATUS="${YELLOW}仅清空${NC}" # Means deletion (意味着删除)
    fi

    local IMPORT_STATUS=""
    if [ "$CLEAR_ONLY" = true ]; then
        IMPORT_STATUS="${YELLOW}不导入${NC}"
    elif [ -z "$IMPORT_FILE" ]; then # If no -f parameter, but not --clear-only (如果未提供 -f 参数,但不是 --clear-only)
        IMPORT_STATUS="${YELLOW}不导入${NC}"
    else
        IMPORT_STATUS="从 ${GREEN}${IMPORT_FILE}${NC} 导入"
    fi

    echo -e "\n${YELLOW}======= 重要警告 =======${NC}"
    echo -e "即将执行以下操作:"
    echo -e "1. ${BACKUP_STATUS} ${DB_NAME}.${COLLECTION_NAME} 的现有数据"
    echo -e "2. ${CLEAR_STATUS} ${DB_NAME}.${COLLECTION_NAME} 中的所有数据"
    echo -e "3. ${IMPORT_STATUS} 新数据"

    # Specific warnings (特定警告)
    if [ "$NO_CLEAR" = true ] && [ -n "$IMPORT_FILE" ]; then
        echo -e "${YELLOW}注意: 您已选择不清空集合。新数据将导入并与现有数据合并。${NC}"
    fi

    echo -e "备份文件将保存到: ${BACKUP_DIR}"
    echo -e "${YELLOW}=======================${NC}\n"

    read -p "您确定要继续吗? (yes/no): " -r
    echo
    if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
        log "INFO" "用户取消了操作"
        exit 0
    fi
}

# Clear target collection (清空目标集合)
clear_collection() {
    log "INFO" "正在清空集合..."
    
    if ! "${MONGO_CLIENT_CMD}" "${MONGO_URI}" --quiet --eval "
        const result = db.${COLLECTION_NAME}.deleteMany({});
        print('Documents deleted: ' + result.deletedCount);
    " >> "$LOG_FILE" 2>&1; then
        log "ERROR" "清空集合失败!"
        return 1 # Indicate failure (指示失败)
    fi
    log "SUCCESS" "集合清空成功。"
    return 0 # Indicate success (指示成功)
}

# Import new data (导入新数据)
import_data() {
    local import_file_path=$1
    local source_desc=$2 # e.g., "new data" or "backup data" (例如,“新数据”或“备份数据”)

    log "INFO" "正在从 ${source_desc} (${import_file_path}) 开始导入数据..."
    
    case "${import_file_path##*.}" in
        json)
            if ! mongoimport ${FULL_OPTIONS} \
                --db "${DB_NAME}" \
                --collection="${COLLECTION_NAME}" \
                --file="${import_file_path}" \
                --jsonArray >> "$LOG_FILE" 2>&1; then
                log "ERROR" "${source_desc} JSON 导入失败!"
                return 1
            fi
            ;;
        bson)
            # IMPORTANT: mongorestore --dir expects a directory, not a single file.
            # If import_file_path is a single .bson file, mongoimport --type bson is usually preferred.
            # This section assumes import_file_path is a directory containing BSON dump.
            if [ ! -d "${import_file_path}" ]; then
                log "ERROR" "对于 BSON 导入,--file 选项应指定包含 BSON 文件的目录,而不是单个文件。"
                log "ERROR" "如果 '${import_file_path}' 是单个 BSON 文件,请考虑使用 'mongoimport --type bson' 命令,或调整输入文件类型。"
                return 1
            fi
            if ! mongorestore ${FULL_OPTIONS} \
                --db "${DB_NAME}" \
                --collection="${COLLECTION_NAME}" \
                --dir="${import_file_path}" \
                --drop >> "$LOG_FILE" 2>&1; then
                log "ERROR" "${source_desc} BSON 导入失败!"
                return 1
            fi
            ;;
        *)
            log "ERROR" "不支持 ${source_desc} 的格式: ${import_file_path##*.}"
            return 1
            ;;
    esac
    
    log "SUCCESS" "数据从 ${source_desc} 导入成功。"
    return 0
}

# Restore data from backup (从备份还原数据)
restore_from_backup() {
    log "ERROR" "正在尝试从备份还原: ${BACKUP_FILE}"
    # Ensure collection is clear before restoring from backup to prevent duplicates if --no-clear was set
    # (在从备份还原之前确保集合已清空,以防止在设置 --no-clear 时出现重复数据)
    if ! clear_collection; then
        log "ERROR" "在从备份还原之前清空集合失败。需要手动干预!"
        return 1
    fi
    if import_data "$BACKUP_FILE" "备份数据"; then
        log "SUCCESS" "成功从备份还原数据。"
        return 0
    else
        log "ERROR" "从备份还原数据失败! 绝对需要手动干预。请检查日志以获取详细信息。"
        return 1
    fi
}

# Verify operation results (验证操作结果)
verify_operation() {
    log "INFO" "正在验证操作结果..."
    
    "${MONGO_CLIENT_CMD}" "${MONGO_URI}" --quiet --eval "
        print('当前文档数量: ' + db.${COLLECTION_NAME}.countDocuments());
        print('示例文档:');
        printjson(db.${COLLECTION_NAME}.find().limit(1).toArray()[0] || '空集合');
    " | tee -a "$LOG_FILE"
}

# Main flow (主流程)
main() {
    # Initialize environment (初始化环境)
    init_env
    # Parse command-line arguments (解析命令行参数)
    parse_arguments "$@"
    # Build connection parameters (构建连接参数)
    build_connection
    
    # Print operation summary (打印操作摘要)
    log "INFO" "操作摘要:"
    log "INFO" "数据库: ${DB_NAME}"
    log "INFO" "集合: ${COLLECTION_NAME}"
    log "INFO" "备份模式: 强制执行备份" # Updated message (更新消息)
    if [ "$NO_CLEAR" = true ]; then
        log "INFO" "清空模式: 跳过清空 (保留现有数据)"
    elif [ "$CLEAR_ONLY" = true ]; then
        log "INFO" "清空模式: 仅清空集合 (不导入新数据)"
    else
        log "INFO" "清空模式: 先清空,然后导入"
    fi

    if [ -n "$IMPORT_FILE" ]; then
        log "INFO" "导入文件: ${IMPORT_FILE}"
    else
        log "INFO" "导入文件: 未指定 (不执行导入)"
    fi
    [ -n "$USERNAME" ] && log "INFO" "认证用户: ${USERNAME}"
    
    # --- 步骤 1: 强制备份现有数据 ---
    # 如果备份失败,脚本将终止。
    if ! backup_data; then
        log "ERROR" "备份失败。中止后续操作。"
        log "INFO" "日志文件: ${LOG_FILE}"
        exit 1
    fi
    
    # --- 步骤 2: 校验还原文件是否存在,存在则执行后续操作 ---
    local proceed_with_modification=false

    if [ -n "$IMPORT_FILE" ]; then
        if validate_file "$IMPORT_FILE" "导入"; then
            log "INFO" "导入文件 '${IMPORT_FILE}' 校验成功。准备进行修改。"
            proceed_with_modification=true
        else
            log "ERROR" "导入文件校验失败。中止清空/导入操作,但备份已存在。"
            log "INFO" "日志文件: ${LOG_FILE}"
            exit 1 # 导入文件无效,退出
        fi
    elif [ "$CLEAR_ONLY" = true ] || [ "$NO_CLEAR" = true ]; then
        # 如果没有导入文件,但设置了 --clear-only 或 --no-clear,我们仍将其视为“修改”意图。
        # --no-clear 且没有导入文件的情况已在 parse_arguments 中捕获。
        log "INFO" "操作为仅清空或与现有数据合并。准备进行修改。"
        proceed_with_modification=true
    else
        # 这种情况理论上应在 parse_arguments 中捕获,但作为回退。
        log "WARN" "未指定导入文件,且未处于仅清空/不清空模式。除备份外,不执行数据导入或清空。"
    fi

    if [ "$proceed_with_modification" = true ]; then
        # --- 步骤 3: 用户确认操作 ---
        confirm_operation

        local import_success=false

        if [ "$NO_CLEAR" != true ]; then
            # --- 步骤 4: 清空目标集合 (如果未跳过) ---
            if ! clear_collection; then
                log "ERROR" "清空集合失败。正在尝试从备份还原。"
                if ! restore_from_backup; then
                    log "CRITICAL" "清空失败后从备份还原也失败。绝对需要手动干预!"
                    exit 1
                fi
                # 如果从备份还原成功,则认为此阶段的“修改”是成功的(即回滚成功)
                import_success=true 
            fi
        else
            log "INFO" "由于 --no-clear 选项,跳过了清空集合步骤。"
        fi
        
        # --- 步骤 5: 导入新数据 (如果指定了文件且不是仅清空模式) ---
        # 只有在清空成功(或跳过清空)且没有发生回滚的情况下才尝试导入新数据
        if [ "$import_success" != true ] && [ -n "$IMPORT_FILE" ] && [ "$CLEAR_ONLY" != true ]; then
            if ! import_data "$IMPORT_FILE" "新数据"; then
                log "ERROR" "新数据导入失败。正在尝试从备份还原。"
                # --- 步骤 6: 如果还原失败则用备份的数据还原 ---
                if ! restore_from_backup; then
                    log "CRITICAL" "导入失败后从备份还原也失败。绝对需要手动干预!"
                    exit 1
                fi
            fi
        else
            log "INFO" "跳过了数据导入步骤。"
        fi
        
        # --- 步骤 7: 验证操作结果 ---
        verify_operation
        
        # 完成消息
        echo -e "\n${GREEN}=== 操作完成 ===${NC}"
        log "SUCCESS" "所有操作完成"
    else
        echo -e "\n${YELLOW}=== 操作完成 (未执行数据修改) ===${NC}"
        log "INFO" "由于导入文件校验失败或未指定操作,未执行数据修改 (清空/导入)。"
    fi

    log "INFO" "备份文件: ${BACKUP_FILE}" # 始终报告备份文件
    log "INFO" "日志文件: ${LOG_FILE}"
}

main "$@"

  

posted @ 2025-07-27 18:04  若水如引  阅读(6)  评论(0)    收藏  举报