1077 lines
42 KiB
Bash
Executable File
1077 lines
42 KiB
Bash
Executable File
#!/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 "=================================================="
|