xWork/gitlab_migration.sh
2026-01-07 10:23:26 +08:00

1077 lines
42 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "=================================================="