#!/bin/bash # ============================================================================== # GitLab 迁移脚本 (v12.x -> v18.x) # ============================================================================== # 功能描述: # 1. 迁移用户 (Users): 账号、邮箱、管理员权限、SSH 密钥。 # 2. 迁移群组 (Groups): 完整层级结构、描述、可见性。 # 3. 迁移项目 (Projects): # - 创建项目元数据 (描述、可见性、默认分支)。 # - 智能解析命名空间 (支持群组和个人命名空间)。 # - 高速并发 Git Mirror 同步 (Clone & Push)。 # 4. 迁移成员 (Members): 恢复群组和项目的成员权限。 # 5. 迁移变量 (Variables): 恢复群组和项目的 CI/CD 变量。 # # 特性: # - 幂等性: 支持断点续传,已迁移对象自动跳过。 # - 安全性: 对源环境 (OLD) 强制只读,保护数据安全。 # - 高性能: 项目迁移采用多进程并发执行。 # ============================================================================== # --- 配置信息 --- # 支持通过环境变量覆盖默认值 (Docker/UI 部署友好) OLD_GITLAB_URL="${OLD_GITLAB_URL:-http://172.25.254.5:10088}" OLD_TOKEN="${OLD_TOKEN:-YJKiyTUEsfCpQ9yMSrwn}" NEW_GITLAB_URL="${NEW_GITLAB_URL:-http://172.23.24.8:32272}" NEW_TOKEN="${NEW_TOKEN:-glpat-jT8miNczJBRh9xRQbNoc}" # 新用户的默认密码 (GitLab 安全策略限制无法迁移原密码) DEFAULT_PASSWORD="${DEFAULT_PASSWORD:-Password123!@#}" # 是否跳过已成功同步 Git 数据的项目 (true/false) # 设置为 true 可在重试时极大加速,仅处理失败的项目 SKIP_EXISTING_REPO="${SKIP_EXISTING_REPO:-true}" # 并发配置 (项目迁移时的并行任务数) MAX_JOBS="${MAX_JOBS:-5}" # 工作目录配置 WORK_DIR="${WORK_DIR:-$(pwd)/migration_data}" LOG_FILE="${LOG_FILE:-$(pwd)/migration.log}" STATE_DIR="${WORK_DIR}/state" REPO_DIR="${WORK_DIR}/repos" # --- 预检查 --- mkdir -p "$STATE_DIR" "$REPO_DIR" command -v jq >/dev/null 2>&1 || { echo >&2 "错误: 未安装 'jq' 工具。"; exit 1; } command -v git >/dev/null 2>&1 || { echo >&2 "错误: 未安装 'git' 工具。"; exit 1; } command -v curl >/dev/null 2>&1 || { echo >&2 "错误: 未安装 'curl' 工具。"; exit 1; } # --- 辅助函数 --- # 定义颜色 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color log() { local level="$1" local message="$2" local timestamp=$(date "+%Y-%m-%d %H:%M:%S") # 文件输出 (纯文本) echo "[$timestamp] [$level] $message" >> "$LOG_FILE" # 终端输出 (带颜色) local color="$NC" case "$level" in INFO) color="$GREEN";; WARN) color="$YELLOW";; ERROR) color="$RED";; FATAL) color="$RED";; DEBUG) color="$BLUE";; esac echo -e "${color}[$timestamp] [$level] $message${NC}" } # API 调用封装 # 用法: api_call "OLD|NEW" "GET|POST|PUT" "/api/v4/..." "data_json" api_call() { local target="$1" local method="$2" local endpoint="$3" local data="$4" local url="" local token="" if [ "$target" == "OLD" ]; then # 安全检查: 强制对旧环境只读 if [ "$method" != "GET" ]; then log "FATAL" "安全错误: 试图对旧 GitLab 环境执行非 GET 操作 ($method)。操作已终止以保护数据安全。" echo "安全错误: 试图对旧 GitLab 环境执行非 GET 操作 ($method)。" >&2 exit 1 fi url="${OLD_GITLAB_URL}${endpoint}" token="${OLD_TOKEN}" else url="${NEW_GITLAB_URL}${endpoint}" token="${NEW_TOKEN}" fi if [ -n "$data" ]; then curl -s -X "$method" "$url" \ -H "Private-Token: $token" \ -H "Content-Type: application/json" \ -d "$data" else curl -s -X "$method" "$url" \ -H "Private-Token: $token" \ -H "Content-Type: application/json" fi } # 获取 API 端点的所有分页数据 (优化:减少进程创建) fetch_all_pages() { local target="$1" local endpoint="$2" local page=1 local per_page=100 local sep="?" if [[ "$endpoint" == *"?"* ]]; then sep="&"; fi # 打印初始进度信息到 stderr (使用 Cyan 颜色) echo -e "${CYAN}正在获取 $target $endpoint 的数据...${NC}" >&2 while true; do local response=$(api_call "$target" "GET" "${endpoint}${sep}per_page=${per_page}&page=${page}") # 优化:直接用 jq 检查长度,避免 echo 管道 if [ "$(jq 'length' <<< "$response")" == "0" ]; then break fi # 输出当前页的数据项 jq -c '.[]' <<< "$response" ((page++)) # 动态刷新进度 (简化输出频率,减少 IO) if (( page % 5 == 0 )); then echo -ne "${CYAN}正在获取第 $page 页...${NC}\r" >&2 fi done echo "" >&2 } # 清理名称 (更加严格,适配 GitLab Name 规则) sanitize_name() { local input="$1" # 1. 替换常见的路径分隔符 / 为 - local cleaned=$(echo "$input" | sed 's/\//-/g') # 2. 仅保留允许的字符: 字母, 数字, 中文, 空格, ., _, -, (, ) # 注意: 在 macOS sed 和 GNU sed 中处理 Unicode/中文比较麻烦 # 这里我们采用“移除明确禁止的特殊字符”的策略,而不是白名单,以兼容中文 # 移除控制字符 cleaned=$(echo "$cleaned" | tr -cd '[:print:]') # 移除首尾空格 cleaned=$(echo "$cleaned" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') # 3. 兜底: 如果为空,生成默认名称 if [ -z "$cleaned" ]; then cleaned="group-name-$(date +%s)" fi echo "$cleaned" } # 清理路径 (更加严格,GitLab URL 路径规则) sanitize_path() { local input="$1" # 1. 移除首尾空格 # 2. 替换非法字符为 - (只允许字母、数字、_、-、.) # 3. 移除开头的 - 或 . # 4. 移除结尾的 - 或 . (GitLab 规则) # 5. 移除 .git 或 .atom 结尾 local cleaned=$(echo "$input" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') cleaned=$(echo "$cleaned" | sed -e 's/[^a-zA-Z0-9._-]/-/g') cleaned=$(echo "$cleaned" | sed -e 's/^[-.]*//' -e 's/[-.]*$//') cleaned=$(echo "$cleaned" | sed -e 's/\.git$//' -e 's/\.atom$//') # 如果清理后为空,给默认值 if [ -z "$cleaned" ]; then cleaned="group-$(date +%s)" fi echo "$cleaned" } # 导出辅助函数以便子进程使用 export -f log export -f api_call export -f fetch_all_pages export -f sanitize_name export -f sanitize_path # 导出环境变量 (显式导出所有可能用到的变量,确保子进程环境一致) export OLD_GITLAB_URL OLD_TOKEN NEW_GITLAB_URL NEW_TOKEN WORK_DIR LOG_FILE STATE_DIR REPO_DIR RED GREEN YELLOW BLUE CYAN NC DEFAULT_PASSWORD # --- 第一阶段: 用户迁移 --- # 单个用户处理逻辑 (独立函数,用于并行调用) process_single_user() { local user_json="$1" # 注意: 这里的 existing_users 关联数组在子shell中无法直接继承使用 # 我们需要一种机制来判断用户是否存在。 # 方案: 在子进程中先查一下 .map 文件,如果不存在,再查 API (或者尝试创建) local old_id=$(echo "$user_json" | jq -r '.id') local username=$(echo "$user_json" | jq -r '.username') local email=$(echo "$user_json" | jq -r '.email') local name=$(echo "$user_json" | jq -r '.name') local is_admin=$(echo "$user_json" | jq -r '.is_admin') # Skip Ghost and bots if necessary if [ "$username" == "ghost" ]; then return 0 fi local new_id="" local user_exists=false # 1. 检查是否已迁移 (通过 map 文件) if [ -f "$STATE_DIR/user_${old_id}.map" ]; then new_id=$(cat "$STATE_DIR/user_${old_id}.map") user_exists=true # log "INFO" "用户 $username 已迁移 (ID: $new_id)。" else # 2. 尝试在新环境查找用户 (避免重复创建) # 注意: 这里的 API 查询是必要的,因为我们不能依赖主进程的内存数组 local search_res=$(api_call "NEW" "GET" "/api/v4/users?username=${username}") # 精确匹配 username new_id=$(echo "$search_res" | jq -r --arg u "$username" '.[] | select(.username == $u) | .id') if [ -n "$new_id" ] && [ "$new_id" != "null" ]; then user_exists=true log "INFO" "用户 $username 已存在 (ID: $new_id)。" else # 如果是 root 且未找到,则跳过 (不尝试创建 root) if [ "$username" == "root" ]; then log "WARN" "在新 GitLab 上未找到 root 用户。跳过。" return 0 fi # 创建用户 log "INFO" "正在创建用户: $username (管理员: $is_admin)" # 构建 JSON 负载 # admin 字段用于设置管理员权限 local payload=$(jq -n \ --arg u "$username" \ --arg e "$email" \ --arg n "$name" \ --arg p "$DEFAULT_PASSWORD" \ --argjson a "$is_admin" \ '{username: $u, email: $e, name: $n, password: $p, skip_confirmation: true, admin: $a}') local response=$(api_call "NEW" "POST" "/api/v4/users" "$payload") new_id=$(echo "$response" | jq -r '.id') if [ "$new_id" == "null" ]; then log "ERROR" "创建用户 $username 失败。响应内容: $response" return 1 fi fi # 保存映射关系: Old_ID -> New_ID echo "$new_id" > "$STATE_DIR/user_${old_id}.map" fi # 2. 同步管理员状态 (针对已存在的用户) if [ "$user_exists" == "true" ] && [ "$is_admin" == "true" ]; then # log "INFO" "确保用户 $username 拥有管理员权限..." api_call "NEW" "PUT" "/api/v4/users/$new_id" "$(jq -n '{admin: true}')" >/dev/null fi # 3. 迁移 SSH 密钥 # log "INFO" "正在为 $username 迁移 SSH 密钥..." while read -r key_json; do local title=$(echo "$key_json" | jq -r '.title') local key=$(echo "$key_json" | jq -r '.key') # 构建 Key Payload local key_payload=$(jq -n --arg t "$title" --arg k "$key" '{title: $t, key: $k}') # POST 到新环境 # 忽略 400 (可能已存在) api_call "NEW" "POST" "/api/v4/users/$new_id/keys" "$key_payload" >/dev/null done < <(fetch_all_pages "OLD" "/api/v4/users/$old_id/keys") } export -f process_single_user migrate_users() { log "INFO" "开始用户迁移..." # 获取旧环境用户 local users_file="$WORK_DIR/all_old_users.json" if [ ! -f "$users_file" ]; then log "INFO" "正在从旧 GitLab 获取用户数据..." fetch_all_pages "OLD" "/api/v4/users" > "$users_file" fi local total_users=$(wc -l < "$users_file" | tr -d ' ') local count=0 # 初始化进度 echo "PROGRESS: 0 / $total_users" # 并发控制配置 # MAX_JOBS 由环境变量控制,默认为 5 local job_count=0 while read -r user_json; do ((count++)) # 优化: 降低日志频率,每处理 10 个用户才打印一次详细日志,但进度条实时更新 # 减少 IO 压力 if (( count % 10 == 0 )); then log "INFO" "正在处理用户 $count / $total_users (并发)" fi echo "PROGRESS: $count / $total_users" # 启动后台任务 ( process_single_user "$user_json" ) & # 并发控制 ((job_count++)) if (( job_count >= MAX_JOBS )); then wait -n 2>/dev/null || wait ((job_count--)) fi done < "$users_file" # 等待所有任务完成 wait log "INFO" "用户迁移完成。共处理 $count 个用户。" } # --- 第二阶段: 群组迁移 --- migrate_groups() { log "INFO" "开始群组迁移..." # 1. 获取旧环境所有群组 # 2. 按 full_path 长度排序 (短路径优先),确保父群组先于子群组创建 # 使用临时文件进行排序 local groups_file="$WORK_DIR/all_old_groups.json" if [ ! -f "$groups_file" ]; then log "INFO" "正在从旧 GitLab 获取所有群组 (可能需要一些时间)..." fetch_all_pages "OLD" "/api/v4/groups" > "$groups_file.tmp" # 按 full_path 长度排序 # jq 技巧: 计算 full_path 长度并据此排序 cat "$groups_file.tmp" | jq -s 'sort_by(.full_path | length) | .[]' -c > "$groups_file" rm "$groups_file.tmp" fi # 在新环境预加载现有群组以实现幂等性 # 映射 "full_path" -> "id" log "INFO" "正在从新 GitLab 加载现有群组..." declare -A existing_groups while read -r group_json; do local fpath=$(echo "$group_json" | jq -r '.full_path') local gid=$(echo "$group_json" | jq -r '.id') existing_groups["$fpath"]=$gid done < <(fetch_all_pages "NEW" "/api/v4/groups") local count=0 local total_groups=$(wc -l < "$groups_file" | tr -d ' ') echo "PROGRESS: 0 / $total_groups" while read -r group_json; do ((count++)) if (( count % 5 == 0 )); then log "INFO" "正在处理群组 $count / $total_groups" fi echo "PROGRESS: $count / $total_groups" local old_id=$(echo "$group_json" | jq -r '.id') local name=$(echo "$group_json" | jq -r '.name') local path=$(echo "$group_json" | jq -r '.path') local full_path=$(echo "$group_json" | jq -r '.full_path') local description=$(echo "$group_json" | jq -r '.description // ""') local visibility=$(echo "$group_json" | jq -r '.visibility') local old_parent_id=$(echo "$group_json" | jq -r '.parent_id') # 增强: 清理 path 和 name 以通过新版 GitLab 的验证 local safe_path=$(sanitize_path "$path") local safe_name=$(sanitize_name "$name") # 如果 path 被修改了,记录日志 if [ "$path" != "$safe_path" ]; then log "WARN" "群组路径 '$path' 已自动修正为 '$safe_path' (原路径包含非法字符)。" fi local new_id="" # 检查是否已处理/存在 (注意: 这里我们用原始 full_path 检查,因为预加载的是 full_path) # 如果路径改变了,新 full_path 也会改变。 # 这里的检查可能需要注意:如果我们在脚本上次运行时创建了 safe_path, # 下次运行时,我们怎么知道 'ids- ' 对应 'ids'? # 我们有 ID 映射文件 group_OLDID.map,这是最可靠的。 if [ -f "$STATE_DIR/group_${old_id}.map" ]; then new_id=$(cat "$STATE_DIR/group_${old_id}.map") # log "INFO" "群组 $full_path 已迁移 (ID: $new_id)。" continue fi # 如果没有映射文件,检查是否在 NEW 上已存在 (通过 full_path) # 注意: 这里的 existing_groups key 是 full_path。 # 如果我们修改了 path,那么 full_path 也会变。 # 但是我们无法轻易预测新的 full_path (因为父群组的 path 可能也变了)。 # 最好的方式是依赖 ID 映射。 if [[ -n "${existing_groups[$full_path]}" ]]; then new_id="${existing_groups[$full_path]}" # log "INFO" "群组 $full_path 已存在 (ID: $new_id)。跳过创建。" else log "INFO" "正在创建群组: $full_path (修正后名称: $safe_name, 路径: $safe_path)" # 解析父群组 ID local new_parent_id="" if [ "$old_parent_id" != "null" ]; then if [ -f "$STATE_DIR/group_${old_parent_id}.map" ]; then new_parent_id=$(cat "$STATE_DIR/group_${old_parent_id}.map") else log "ERROR" "未找到群组 $full_path 的父群组 ID $old_parent_id。跳过。" continue fi fi # 构建负载 (使用 safe_name 和 safe_path) local payload="" if [ -n "$new_parent_id" ]; then payload=$(jq -n \ --arg n "$safe_name" \ --arg p "$safe_path" \ --arg d "$description" \ --arg v "$visibility" \ --arg pid "$new_parent_id" \ '{name: $n, path: $p, description: $d, visibility: $v, parent_id: $pid}') else payload=$(jq -n \ --arg n "$safe_name" \ --arg p "$safe_path" \ --arg d "$description" \ --arg v "$visibility" \ '{name: $n, path: $p, description: $d, visibility: $v}') fi local response=$(api_call "NEW" "POST" "/api/v4/groups" "$payload") new_id=$(echo "$response" | jq -r '.id') if [ "$new_id" == "null" ]; then log "ERROR" "创建群组 $full_path 失败。响应内容: $response" continue fi fi # 保存映射 echo "$new_id" > "$STATE_DIR/group_${old_id}.map" done < "$groups_file" log "INFO" "群组迁移完成。共处理 $count 个群组。" } # --- 第三阶段: 项目迁移 --- # 单个项目处理逻辑 (独立函数,用于并行调用) process_single_project() { local proj_json="$1" local old_id=$(echo "$proj_json" | jq -r '.id') local name=$(echo "$proj_json" | jq -r '.name') local path=$(echo "$proj_json" | jq -r '.path') # 即 slug local description=$(echo "$proj_json" | jq -r '.description // ""') local visibility=$(echo "$proj_json" | jq -r '.visibility') local default_branch=$(echo "$proj_json" | jq -r '.default_branch // "master"') # 自动修正项目路径 (容错处理) # 例如: "s-88-" -> "s-88" local safe_path=$(sanitize_path "$path") if [ "$path" != "$safe_path" ]; then log "WARN" "项目路径 '$path' 已自动修正为 '$safe_path' (原路径包含非法字符)。" fi # 命名空间处理 local old_ns_id=$(echo "$proj_json" | jq -r '.namespace.id') local old_ns_kind=$(echo "$proj_json" | jq -r '.namespace.kind') # group 或 user local old_ns_path=$(echo "$proj_json" | jq -r '.namespace.path') local path_with_namespace=$(echo "$proj_json" | jq -r '.path_with_namespace') local http_url_to_repo=$(echo "$proj_json" | jq -r '.http_url_to_repo') # 确定新命名空间 ID local new_ns_id="" if [ "$old_ns_kind" == "group" ]; then if [ -f "$STATE_DIR/group_${old_ns_id}.map" ]; then new_ns_id=$(cat "$STATE_DIR/group_${old_ns_id}.map") else log "ERROR" "项目 $path_with_namespace 的命名空间群组 ID $old_ns_id 未映射。跳过。" return 1 fi else # User namespace - Resolve dynamically log "INFO" "正在解析个人项目 $path_with_namespace 的命名空间..." # Search for namespace on NEW by path (username) # We assume the username is the same. local ns_response=$(api_call "NEW" "GET" "/api/v4/namespaces?search=${old_ns_path}") # Select the one where path == old_ns_path and kind == 'user' # Note: The search is fuzzy, so we must filter exactly. new_ns_id=$(echo "$ns_response" | jq -r --arg p "$old_ns_path" '.[] | select(.path == $p and .kind == "user") | .id') if [ -z "$new_ns_id" ] || [ "$new_ns_id" == "null" ]; then log "WARN" "无法在新 GitLab 上找到个人命名空间 '$old_ns_path'。跳过。" return 1 fi fi # 检查项目是否在新环境中存在 (通过路径) # 可以构建新路径并检查 # 但更简单的方法是: 直接尝试创建。如果返回 400,可能已存在。 # 构建创建负载 local payload=$(jq -n \ --arg n "$name" \ --arg p "$safe_path" \ --arg d "$description" \ --arg v "$visibility" \ --arg ns "$new_ns_id" \ '{name: $n, path: $p, description: $d, visibility: $v, namespace_id: $ns}') local new_proj_json=$(api_call "NEW" "POST" "/api/v4/projects" "$payload") local new_id=$(echo "$new_proj_json" | jq -r '.id') local new_http_url=$(echo "$new_proj_json" | jq -r '.http_url_to_repo') if [ "$new_id" == "null" ]; then # 增强错误解析: 检查 message, error 和 base (Gitaly 错误通常在 base 数组中) # 注意:API 错误响应格式可能多种多样,例如: # {"message": "has already been taken"} # {"message": {"name": ["has already been taken"]}} # {"error": "..."} # 尝试提取所有可能的错误信息字符串 local msg=$(echo "$new_proj_json" | jq -r '.message // .error // (.base | join("; ")) // empty') # 如果 jq 提取失败(例如复杂对象),则直接转储整个 JSON,但移除换行以便日志记录 if [ -z "$msg" ] || [ "$msg" == "null" ]; then msg=$(echo "$new_proj_json" | tr -d '\n') fi # 将错误信息转换为字符串进行匹配 (防止 jq 返回对象导致的匹配失效) msg_str="$msg" if [[ "$msg_str" == *"taken"* ]]; then log "WARN" "项目 $path_with_namespace 似乎已存在。尝试查找现有项目以继续同步..." # 尝试通过 search 查找精确匹配的项目 # 注意:搜索可能返回多个结果,我们需要根据 namespace_id 和 path 进行精确过滤 # 使用 simple=false 以获取 namespace 信息 local search_res=$(api_call "NEW" "GET" "/api/v4/projects?search=${safe_path}&simple=false") # 使用 jq 过滤:path == $safe_path AND namespace.id == $new_ns_id local existing_proj=$(echo "$search_res" | jq -r --arg p "$safe_path" --argjson ns "$new_ns_id" '.[] | select(.path == $p and .namespace.id == $ns) | select(.id != null)') new_id=$(echo "$existing_proj" | jq -r '.id') new_http_url=$(echo "$existing_proj" | jq -r '.http_url_to_repo') if [ -n "$new_id" ] && [ "$new_id" != "null" ]; then log "INFO" "找到已存在的项目 $path_with_namespace (ID: $new_id),将尝试更新代码。" # 赋值 new_proj_json 以便后续逻辑使用(例如获取 path_with_namespace) new_proj_json="$existing_proj" # 保存映射,以防之前没保存 echo "$new_id" > "$STATE_DIR/project_${old_id}.map" # 继续执行后续的 Git 同步,不返回 else log "ERROR" "项目提示已存在,但无法通过 API 查找到 (Path: $safe_path, Namespace ID: $new_ns_id)。跳过。" return 1 fi elif [[ "$msg" == *"Gitaly"* ]] || [[ "$msg" == *"connect failed"* ]] || [[ "$msg" == *"No such file"* ]]; then log "FATAL" "创建项目 $path_with_namespace 失败: GitLab 服务端 Gitaly 异常。错误信息: $msg" return 1 else log "ERROR" "创建项目 $path_with_namespace 失败: $msg" # 关键修复: 创建失败必须立即停止,不能继续 Push return 1 fi else log "INFO" "已创建项目 $path_with_namespace (ID: $new_id)" echo "$new_id" > "$STATE_DIR/project_${old_id}.map" fi # --- GIT 镜像同步 --- # 仅当我们有有效的新 URL (或找到现有的) 时才继续 if [ -n "$new_http_url" ] && [ "$new_http_url" != "null" ]; then # 检查是否已完成 Git 同步 if [ "$SKIP_EXISTING_REPO" == "true" ] && [ -f "$STATE_DIR/project_${old_id}.git_done" ]; then log "INFO" "项目 $path_with_namespace Git 数据此前已同步成功。跳过。" return 0 fi log "INFO" "正在为 $path_with_namespace 镜像 Git 仓库..." # 修正: 手动构建 Git URL 以避免使用 GitLab 内部容器主机名 (例如 gitlab-ce-xxxx) # 使用配置的外部 IP/域名进行连接 # 构建 Clone URL (Old) local old_base_url="${OLD_GITLAB_URL#*://}" local clone_url="http://oauth2:${OLD_TOKEN}@${old_base_url}/${path_with_namespace}.git" # 构建 Push URL (New) # 关键修复: 不能直接使用旧的 path_with_namespace,因为父群组路径可能已改变 # 我们需要获取新项目的完整路径 (full_path) # 由于我们刚刚创建了项目(或查到了),我们可以查一下它的 full_path # 为了减少 API 调用,我们可以利用 new_proj_json 中的 path_with_namespace (如果是新建的) # 或者直接查一次 API (如果是已存在的) local new_full_path="" if [ -n "$new_proj_json" ] && [ "$(echo "$new_proj_json" | jq -r '.id')" != "null" ]; then new_full_path=$(echo "$new_proj_json" | jq -r '.path_with_namespace') fi # 如果还是空的(比如之前逻辑跳过了创建),我们需要查一下 if [ -z "$new_full_path" ] || [ "$new_full_path" == "null" ]; then # 尝试通过 new_id 获取 if [ -n "$new_id" ]; then local p_info=$(api_call "NEW" "GET" "/api/v4/projects/$new_id") new_full_path=$(echo "$p_info" | jq -r '.path_with_namespace') fi fi if [ -z "$new_full_path" ] || [ "$new_full_path" == "null" ]; then log "ERROR" "无法获取项目 $path_with_namespace 在新环境的完整路径。跳过 Git 同步。" return 1 fi local new_base_url="${NEW_GITLAB_URL#*://}" # 使用新环境真实的 full_path 构建 URL local push_url="http://oauth2:${NEW_TOKEN}@${new_base_url}/${new_full_path}.git" # 使用唯一目录避免冲突 local repo_path="$REPO_DIR/${path}_${old_id}.git" rm -rf "$repo_path" # 捕获 git clone 的错误输出 # 使用 --mirror 克隆以获取所有数据,但在 push 时过滤 local clone_output clone_output=$(git clone --mirror "$clone_url" "$repo_path" 2>&1) local clone_status=$? if [ $clone_status -eq 0 ]; then cd "$repo_path" # 修复: git clone --mirror 会自动设置 remote.origin.mirror=true # 这会导致后续手动指定 refspecs 推送时报错 "fatal: --mirror can't be combined with refspecs" # 我们必须先取消这个设置 git config --unset remote.origin.mirror # --- 增强调试日志 --- # 1. 打印本地仓库大小,证明 clone 确实拉到了文件 local repo_size=$(du -sh . | cut -f1) log "INFO" "本地仓库已就绪,大小: $repo_size (路径: $repo_path)" # 2. 打印即将推送的目标地址,供人工核对 # 隐藏 token 避免泄露 (简单的 sed 替换) local safe_push_url=$(echo "$push_url" | sed 's/:[^@]*@/:***@/') log "INFO" "准备将代码推送到: $safe_push_url" # 安全检查: 检查本地仓库是否为空 (没有任何引用) # 如果源仓库本身就是空的 (无分支/提交),那么这是正常的,不需要 Push # 如果源仓库非空但这里是空的,说明 Clone 可能有问题 (但这种情况 git clone 通常会报错) # 获取所有引用 (heads 和 tags) local ref_count=$(git show-ref | wc -l) if [ "$ref_count" -eq 0 ]; then log "WARN" "项目 $path_with_namespace 是空仓库 (无分支/标签)。跳过推送。" else # 捕获 git push 的错误输出 # 关键修复: 不使用 --mirror,而是显式推送分支和标签 # 必须使用计算出的 push_url (新环境),而不是 origin (旧环境) local push_output push_output=$(git push "$push_url" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*" 2>&1) local push_status=$? if [ $push_status -eq 0 ]; then log "INFO" "项目 $path_with_namespace Git 镜像同步成功" # 更新默认分支 if [ -n "$default_branch" ] && [ "$default_branch" != "null" ]; then # 忽略 404/400 错误 (比如该分支没推上去,虽然不太可能) api_call "NEW" "PUT" "/api/v4/projects/$new_id" "$(jq -n --arg b "$default_branch" '{default_branch: $b}')" >/dev/null fi # 标记为 Git 同步成功 touch "$STATE_DIR/project_${old_id}.git_done" else log "ERROR" "项目 $path_with_namespace Git 推送失败。原因: $push_output" fi fi cd "$WORK_DIR" rm -rf "$repo_path" # 清理 else log "ERROR" "项目 $path_with_namespace Git 克隆失败。原因: $clone_output" fi fi } export -f process_single_project migrate_projects() { log "INFO" "开始项目迁移..." # 获取所有项目 local projects_file="$WORK_DIR/all_old_projects.json" if [ ! -f "$projects_file" ]; then log "INFO" "正在从旧 GitLab 获取所有项目..." fetch_all_pages "OLD" "/api/v4/projects" > "$projects_file" fi local count=0 local total_projects=$(wc -l < "$projects_file" | tr -d ' ') echo "PROGRESS: 0 / $total_projects" # 并发控制配置 # MAX_JOBS 由环境变量控制,默认为 5 local job_count=0 while read -r proj_json; do ((count++)) if (( count % 5 == 0 )); then log "INFO" "正在处理项目 $count / $total_projects (并发)" fi echo "PROGRESS: $count / $total_projects" # 启动后台任务 ( process_single_project "$proj_json" ) & # 并发控制: 如果达到最大任务数,等待任意一个结束 ((job_count++)) if (( job_count >= MAX_JOBS )); then # wait -n 等待任意一个子进程结束 (Bash 4.3+) # 如果 wait -n 失败 (兼容性),则回退到 wait (等待所有) wait -n 2>/dev/null || wait ((job_count--)) fi done < "$projects_file" # 等待所有剩余的后台任务完成 wait log "INFO" "项目迁移完成。共处理 $count 个项目。" } # --- 第四阶段: 成员迁移 --- migrate_members() { log "INFO" "开始成员迁移..." # 辅助函数: 权限等级转文字 get_access_name() { case "$1" in 10) echo "Guest" ;; 20) echo "Reporter" ;; 30) echo "Developer" ;; 40) echo "Maintainer" ;; 50) echo "Owner" ;; *) echo "Level_$1" ;; esac } # 辅助函数:添加成员并处理结果 add_member() { local resource_type="$1" # groups 或 projects local resource_id="$2" local user_id="$3" local access_level="$4" local resource_name="$5" # 用于日志显示 (Full Path) local username="$6" # 用户名 local level_name=$(get_access_name "$access_level") # 构建 Payload local payload=$(jq -n \ --arg u "$user_id" \ --arg a "$access_level" \ '{user_id: $u, access_level: $a}') # 调用 API local response=$(api_call "NEW" "POST" "/api/v4/$resource_type/$resource_id/members" "$payload") local new_member_id=$(echo "$response" | jq -r '.id') if [ "$new_member_id" != "null" ]; then log "INFO" "[$resource_type] $resource_name: 添加成员成功 - 用户: $username (ID: $user_id), 权限: $level_name" else local msg=$(echo "$response" | jq -r '.message // .error // "Unknown"') local msg_str="$msg" if [[ "$msg_str" == *"Member already exists"* ]] || [[ "$msg_str" == *"already exists"* ]]; then log "WARN" "[$resource_type] $resource_name: 成员已存在 - 用户: $username, 权限: $level_name (跳过)" elif [[ "$msg_str" == *"继承"* ]] || [[ "$msg_str" == *"inherited"* ]] || [[ "$msg_str" == *"higher"* ]]; then log "WARN" "[$resource_type] $resource_name: 继承权限冲突 - 用户: $username 已拥有更高/相同权限 (跳过显式添加)" else log "ERROR" "[$resource_type] $resource_name: 添加成员失败 - 用户: $username, 原因: $msg_str" fi fi } # 预加载项目和群组名称映射 (ID -> Full Path) # 这会花费几秒钟,但能极大提高日志可读性 log "INFO" "正在构建名称索引以优化日志显示..." declare -A group_names declare -A project_names if [ -f "$WORK_DIR/all_old_groups.json" ]; then while read -r line; do gid=$(echo "$line" | jq -r '.id') gpath=$(echo "$line" | jq -r '.full_path') group_names[$gid]="$gpath" done < "$WORK_DIR/all_old_groups.json" fi if [ -f "$WORK_DIR/all_old_projects.json" ]; then while read -r line; do pid=$(echo "$line" | jq -r '.id') ppath=$(echo "$line" | jq -r '.path_with_namespace') project_names[$pid]="$ppath" done < "$WORK_DIR/all_old_projects.json" fi # 1. 项目成员 (优先迁移,避免被父群组权限覆盖导致无法添加直接成员) log "INFO" "正在迁移项目成员..." # 统计项目总数 local total_projects=$(ls "$STATE_DIR"/project_*.map 2>/dev/null | wc -l | tr -d ' ') echo "PROGRESS: 0 / $total_projects" local count=0 for map_file in "$STATE_DIR"/project_*.map; do [ -e "$map_file" ] || continue ((count++)) # 实时进度 echo "PROGRESS: $count / $total_projects" local old_pid="${map_file##*_}" old_pid="${old_pid%.map}" local new_pid=$(cat "$map_file") local p_name="${project_names[$old_pid]}" [ -z "$p_name" ] && p_name="Project_$old_pid" local project_label="$p_name" if (( count % 50 == 0 )); then log "INFO" "正在处理项目成员: $count / $total_projects" fi while read -r member_json; do local old_uid=$(echo "$member_json" | jq -r '.id') local access_level=$(echo "$member_json" | jq -r '.access_level') local username=$(echo "$member_json" | jq -r '.username') if [ -f "$STATE_DIR/user_${old_uid}.map" ]; then local new_uid=$(cat "$STATE_DIR/user_${old_uid}.map") add_member "projects" "$new_pid" "$new_uid" "$access_level" "$project_label" "$username" else if [ "$username" != "ghost" ]; then log "WARN" "项目 $p_name: 无法迁移成员 $username (Old ID: $old_uid)。原因: 未找到用户映射。" fi fi done < <(fetch_all_pages "OLD" "/api/v4/projects/$old_pid/members") done # 2. 群组成员 log "INFO" "正在迁移群组成员..." # 统计群组总数 local total_groups=$(ls "$STATE_DIR"/group_*.map 2>/dev/null | wc -l | tr -d ' ') echo "PROGRESS: 0 / $total_groups" count=0 for map_file in "$STATE_DIR"/group_*.map; do [ -e "$map_file" ] || continue ((count++)) # 实时进度 echo "PROGRESS: $count / $total_groups" local old_gid="${map_file##*_}" old_gid="${old_gid%.map}" local new_gid=$(cat "$map_file") # 使用预加载的名称,如果没有则回退到 ID local g_name="${group_names[$old_gid]}" [ -z "$g_name" ] && g_name="Group_$old_gid" local group_label="$g_name" if (( count % 10 == 0 )); then log "INFO" "正在处理群组成员: $count / $total_groups" fi while read -r member_json; do local old_uid=$(echo "$member_json" | jq -r '.id') local access_level=$(echo "$member_json" | jq -r '.access_level') local username=$(echo "$member_json" | jq -r '.username') # 映射用户 if [ -f "$STATE_DIR/user_${old_uid}.map" ]; then local new_uid=$(cat "$STATE_DIR/user_${old_uid}.map") add_member "groups" "$new_gid" "$new_uid" "$access_level" "$group_label" "$username" else log "WARN" "群组 $g_name: 无法迁移成员 $username (Old ID: $old_uid)。原因: 未找到用户映射 (用户迁移可能失败或被跳过)。" fi done < <(fetch_all_pages "OLD" "/api/v4/groups/$old_gid/members") done log "INFO" "成员迁移完成。" } # --- 第五阶段: CI/CD 变量迁移 --- migrate_variables() { log "INFO" "开始 CI/CD 变量迁移..." # 1. 迁移群组变量 log "INFO" "正在迁移群组变量..." for map_file in "$STATE_DIR"/group_*.map; do [ -e "$map_file" ] || continue local old_gid="${map_file##*_}" old_gid="${old_gid%.map}" local new_gid=$(cat "$map_file") while read -r var_json; do local key=$(echo "$var_json" | jq -r '.key') local value=$(echo "$var_json" | jq -r '.value') local variable_type=$(echo "$var_json" | jq -r '.variable_type') local protected=$(echo "$var_json" | jq -r '.protected') local masked=$(echo "$var_json" | jq -r '.masked') local description=$(echo "$var_json" | jq -r '.description // ""') # 构建负载 local payload=$(jq -n \ --arg k "$key" \ --arg v "$value" \ --arg vt "$variable_type" \ --argjson p "$protected" \ --argjson m "$masked" \ --arg d "$description" \ '{key: $k, value: $v, variable_type: $vt, protected: $p, masked: $m, description: $d}') # 创建变量 (POST) # 如果变量已存在,会返回 400,我们忽略它 api_call "NEW" "POST" "/api/v4/groups/$new_gid/variables" "$payload" >/dev/null done < <(fetch_all_pages "OLD" "/api/v4/groups/$old_gid/variables") done # 2. 迁移项目变量 log "INFO" "正在迁移项目变量..." local count=0 local total_projects=$(ls "$STATE_DIR"/project_*.map 2>/dev/null | wc -l | tr -d ' ') echo "PROGRESS: 0 / $total_projects" for map_file in "$STATE_DIR"/project_*.map; do [ -e "$map_file" ] || continue ((count++)) echo "PROGRESS: $count / $total_projects" local old_pid="${map_file##*_}" old_pid="${old_pid%.map}" local new_pid=$(cat "$map_file") while read -r var_json; do local key=$(echo "$var_json" | jq -r '.key') local value=$(echo "$var_json" | jq -r '.value') local variable_type=$(echo "$var_json" | jq -r '.variable_type') local protected=$(echo "$var_json" | jq -r '.protected') local masked=$(echo "$var_json" | jq -r '.masked') local environment_scope=$(echo "$var_json" | jq -r '.environment_scope') local description=$(echo "$var_json" | jq -r '.description // ""') local payload=$(jq -n \ --arg k "$key" \ --arg v "$value" \ --arg vt "$variable_type" \ --argjson p "$protected" \ --argjson m "$masked" \ --arg es "$environment_scope" \ --arg d "$description" \ '{key: $k, value: $v, variable_type: $vt, protected: $p, masked: $m, environment_scope: $es, description: $d}') api_call "NEW" "POST" "/api/v4/projects/$new_pid/variables" "$payload" >/dev/null done < <(fetch_all_pages "OLD" "/api/v4/projects/$old_pid/variables") done log "INFO" "CI/CD 变量迁移完成。" } # --- 主执行流程 --- echo "==================================================" echo " GitLab 迁移脚本 " echo "==================================================" echo "日志文件: $LOG_FILE" echo "状态目录: $STATE_DIR" usage() { echo "用法: $0 [all|users|groups|projects|members|variables]" echo " all : 执行所有迁移步骤 (默认)" echo " users : 仅迁移用户" echo " groups : 仅迁移群组" echo " projects : 仅迁移项目" echo " members : 仅迁移成员关系" echo " variables : 仅迁移 CI/CD 变量" exit 1 } # 如果没有参数,默认执行 all MODE="${1:-all}" case "$MODE" in all) migrate_users migrate_groups migrate_projects migrate_members migrate_variables ;; users) migrate_users ;; groups) migrate_groups ;; projects) migrate_projects ;; members) migrate_members ;; variables) migrate_variables ;; *) echo "错误: 未知模式 '$MODE'" usage ;; esac echo "==================================================" echo " 迁移结束! " echo " 请查看 $LOG_FILE 获取详细信息。" echo "=================================================="