用webhooks实现主从Gitlab的代码同步

用webhooks实现主从Gitlab的代码同步

在实际工作中,我们经常会遇到一种较为复杂的 GitLab 同步需求:

🎯 需求背景
A 地部署了一个私有 GitLab(称为【主 GitLab】),包含多个根群组及其下属仓库。
B 地需要部署一个【从 GitLab】,要求:
仅同步【主 GitLab】中指定的根群组及其仓库;
保持仓库路径结构完全一致;
实现近实时同步——当主 GitLab 上发生 push、merge 等操作时,从 GitLab 能立即同步对应仓库的内容。

🔧 当前解决方案
为满足上述需求,我设计了一套“定期发现 + 按需同步”的组合方案,包含两个核心组件:
1️⃣ Shell 脚本:定期扫描并创建镜像仓库
每隔 10 分钟调用 GitLab API,对比【主/从 GitLab】中指定根群组下的仓库列表;
若发现【主 GitLab】有新增仓库,则在【从 GitLab】的对应路径下自动创建同名仓库;
创建时配置为 Pull Mirror(拉取镜像),默认每 30 分钟自动同步一次,并设置为“以主仓库为准”。
如果个别仓库很大,比如5G以上,可以用git 拉取到本地,然后强制推送到【从Gitlab】。以脚本为主。
也有一个脚本实现了这个功能。
✅ 优点:确保仓库结构与路径一致,自动覆盖缺失项。
⚠️ 局限:镜像同步频率受限,无法做到事件驱动的实时性。

2️⃣ Python Webhook 服务:实现按需实时同步
启动一个轻量级 Webhook 服务,监听来自【主 GitLab】的事件;
将该服务地址注册到【主 GitLab】中目标根群组的 Webhook 列表中;
当群组内任意仓库触发 push、merge request 等事件时,Webhook 服务会:
自动识别事件所属仓库;
调用【从 GitLab】API 立即触发该仓库的镜像同步。

✅ 优势:
按需同步,避免全量轮询带来的资源浪费;
低延迟,几乎在代码提交后秒级完成同步;
精准匹配路径,确保主从仓库结构完全一致。
📦 组成说明
后续我会提供:
完整的 Shell 脚本(用于仓库发现与镜像初始化);
Python Webhook 服务代码(基于 Flask/FastAPI);
配置与部署指南(包括 Webhook 注册、权限设置等)。
这套方案已在生产环境中稳定运行,兼顾了结构一致性、同步实时性与系统资源效率。

1.定期扫描并创建镜像仓库Shell脚本如下:

解释配置区:
提前在【主从Gitlab】都创建一个管理员账户,例如 rsync
然后分别登录,找到【访问令牌】,各自都添加一个访问令牌,勾选相应的权限范围(全勾就完事)
准备好【主从Gitlab】访问地址,域名或IP地址均可,看自己。
准备好,需要同步的根群组名称

配置区出现的 大仓库配置,就是意味着可以通过手动拉取很大的仓库,不是特别适合用镜像仓库同步的。
当前的脚本他会检测到大仓库的仓库名称,然后跳过设置同步。
后面给你大仓库的shell脚本

#!/bin/bash

# 功能:同步主从GitLab的群组和项目
# 以主GitLab为基准,创建[从GitLab]中不存在的根群组、子群组和项目,并设置镜像
# 始终以主Gitlab为基准,就算有冲突,【从Gitlab】也是以主Gitlab为主,强制同步
# =============== 配置区 ===============
SLAVE_TOKEN="xxxxxxxxxxxxxxxxxxxxx"    # 从 GitLab Token
SLAVE_URL="http://slavegitlab.com"   # 从 GitLab 地址
MASTER_TOKEN="xxxxxxxxxxxxxxxxxxxxx"   # 主 GitLab Token
MASTER_URL="http://mastergitlab.com"            # 主 GitLab 地址
MASTER_USERNAME="rsync"            # 主 GitLab 用户名,用来同步的用户
ROOT_GROUPS=("xxxc" "xxx" "xxx" "xxx")     # 要同步的根群组
# ======================================
# 大仓库清单文件路径
LARGE_PROJECTS_FILE="/data/docker-compose/gitlab/sync_script/large_projects.txt"


# 加载大仓库集合
declare -A LARGE_PROJECTS_MAP
if [ -f "$LARGE_PROJECTS_FILE" ]; then
    while IFS= read -r line; do
        line=$(echo "$line" | xargs)  # 去除前后空格
        if [ -n "$line" ] && [[ ! "$line" =~ ^# ]]; then  # 跳过空行和注释
            LARGE_PROJECTS_MAP["$line"]=1
        fi
    done < "$LARGE_PROJECTS_FILE"
fi

# 日志函数
log() { echo -e "\033[1;32m$1\033[0m"; }
error() { echo -e "\033[1;31m$1\033[0m"; }
warn() { echo -e "\033[1;33m$1\033[0m"; }
info() { echo -e "\033[1;34m$1\033[0m"; }

urlencode() {
    echo "$1" | sed 's|/|%2F|g'
}

# 检查API响应是否有效
check_api_response() {
    local response="$1"
    local context="$2"

    if ! echo "$response" | jq -e . >/dev/null 2>&1; then
        error "API响应无效 ($context)"
        return 1
    fi
    return 0
}

# 递归获取所有群组(包括所有层级)
get_all_groups_recursive() {
    local base_group="$1"
    local token="$2"
    local url="$3"
    local master_or_slave="$4"

    local encoded=$(urlencode "$base_group")
    declare -a all_groups=()
    local total_count=0

    # 检查根群组是否存在
    local root_check=$(curl -s -o /dev/null -w "%{http_code}" \
        --header "PRIVATE-TOKEN: $token" \
        "$url/api/v4/groups/$encoded")

    if [ "$root_check" != "200" ]; then
        if [ "$master_or_slave" == "master" ]; then
            error "根群组 '$base_group' 在${master_or_slave}GitLab中不存在!"
            return 1
        else
            warn "根群组 '$base_group' 在${master_or_slave}GitLab中不存在"
            echo "[]"
            return 0
        fi
    fi

    # 获取根群组信息
    local root_resp=$(curl -s --header "PRIVATE-TOKEN: $token" \
        "$url/api/v4/groups/$encoded")

    if check_api_response "$root_resp" "获取根群组"; then
        all_groups+=("$root_resp")
        ((total_count++))
    fi

    # 使用栈来实现广度优先搜索,获取所有层级的子群组
    local page=1
    local processed_groups=0

    # 初始群组ID
    local root_id=$(echo "$root_resp" | jq -r '.id')

    # 使用队列处理所有群组
    declare -a queue=("$root_id")

    while [ ${#queue[@]} -gt 0 ]; do
        local current_id="${queue[0]}"
        queue=("${queue[@]:1}")  # 移除队列中的第一个元素

        page=1
        while true; do
            local resp=$(curl -s --header "PRIVATE-TOKEN: $token" \
                "$url/api/v4/groups/$current_id/subgroups?per_page=100&page=$page")

            if ! check_api_response "$resp" "获取子群组"; then
                break
            fi

            local count=$(echo "$resp" | jq -r 'length' 2>/dev/null || echo 0)
            if [ "$count" -eq 0 ]; then break; fi

            for ((i=0; i<count; i++)); do
                local group=$(echo "$resp" | jq -c ".[$i]")
                all_groups+=("$group")
                ((total_count++))

                # 获取子群组的ID,添加到队列中继续处理
                local subgroup_id=$(echo "$group" | jq -r '.id')
                if [ "$subgroup_id" != "null" ] && [ -n "$subgroup_id" ]; then
                    queue+=("$subgroup_id")
                fi
            done

            ((page++))
        done

        ((processed_groups++))
    done

    # 将数组转换为JSON数组字符串
    local result="["
    for ((i=0; i<${#all_groups[@]}; i++)); do
        if [ $i -gt 0 ]; then
            result="$result,"
        fi
        result="$result${all_groups[$i]}"
    done
    result="$result]"

    echo "$result"
    return 0
}

# 递归获取所有项目(包括所有层级)
get_all_projects_recursive() {
    local base_group="$1"
    local token="$2"
    local url="$3"
    local master_or_slave="$4"

    local encoded=$(urlencode "$base_group")
    declare -a all_projects=()
    local total_count=0

    # 检查根群组是否存在
    local root_check=$(curl -s -o /dev/null -w "%{http_code}" \
        --header "PRIVATE-TOKEN: $token" \
        "$url/api/v4/groups/$encoded")

    if [ "$root_check" != "200" ]; then
        if [ "$master_or_slave" == "master" ]; then
            error "根群组 '$base_group' 在${master_or_slave}GitLab中不存在!"
            return 1
        else
            warn "根群组 '$base_group' 在${master_or_slave}GitLab中不存在"
            echo "[]"
            return 0
        fi
    fi

    # 获取所有项目(使用include_subgroups参数获取所有层级的项目)
    local page=1
    while true; do
        local resp=$(curl -s --header "PRIVATE-TOKEN: $token" \
            "$url/api/v4/groups/$encoded/projects?per_page=100&page=$page&include_subgroups=true")

        if ! check_api_response "$resp" "获取项目"; then
            break
        fi

        local count=$(echo "$resp" | jq -r 'length' 2>/dev/null || echo 0)
        if [ "$count" -eq 0 ]; then break; fi

        for ((i=0; i<count; i++)); do
            local project=$(echo "$resp" | jq -c ".[$i]")
            all_projects+=("$project")
            ((total_count++))
        done

        ((page++))
    done

    # 将数组转换为JSON数组字符串
    local result="["
    for ((i=0; i<${#all_projects[@]}; i++)); do
        if [ $i -gt 0 ]; then
            result="$result,"
        fi
        result="$result${all_projects[$i]}"
    done
    result="$result]"

    echo "$result"
    return 0
}

# 检查群组是否存在(通过API)
group_exists_on_slave() {
    local group_path="$1"
    local encoded=$(urlencode "$group_path")
    local http_code=$(curl -s -o /dev/null -w "%{http_code}" \
        --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
        "$SLAVE_URL/api/v4/groups/$encoded")
    [ "$http_code" == "200" ]
}

# 检查项目是否存在(通过API)
project_exists_on_slave() {
    local project_path="$1"
    local encoded=$(urlencode "$project_path")
    local http_code=$(curl -s -o /dev/null -w "%{http_code}" \
        --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
        "$SLAVE_URL/api/v4/projects/$encoded")
    [ "$http_code" == "200" ]
}

# 检查项目是否已设置镜像
project_has_mirror() {
    local project_path="$1"
    local encoded=$(urlencode "$project_path")

    local resp=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
        "$SLAVE_URL/api/v4/projects/$encoded")

    if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
        return 1  # 项目不存在或响应无效
    fi

    local mirror=$(echo "$resp" | jq -r '.mirror // false')
    [[ "$mirror" == "true" ]]
}

# 创建群组(递归)
create_group_on_slave() {
    local full_path="$1"

    # 如果群组已存在,直接返回
    if group_exists_on_slave "$full_path"; then
        log "群组 '$full_path' 已存在"
        return 0
    fi

    # 分解路径,逐级创建
    IFS='/' read -ra parts <<< "$full_path"
    local current=""
    local parent_id=""

    for part in "${parts[@]}"; do
        if [ -z "$current" ]; then
            current="$part"
        else
            current="$current/$part"
        fi

        # 检查当前层级是否已存在
        if ! group_exists_on_slave "$current"; then
            log "创建群组: $current"

            # 获取父群组ID(如果不是根群组)
            if [ -n "$parent_id" ]; then
                local data="{\"name\":\"$part\",\"path\":\"$part\",\"parent_id\":$parent_id}"
            else
                local data="{\"name\":\"$part\",\"path\":\"$part\"}"
            fi

            # 创建群组
            local resp=$(curl -s -w "%{http_code}" --request POST \
                --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
                --header "Content-Type: application/json" \
                --data "$data" \
                "$SLAVE_URL/api/v4/groups")

            local code="${resp: -3}"
            local body="${resp%???}"

            if [[ "$code" != "201" ]]; then
                error "创建群组失败: $current (HTTP $code)"
                echo "$body" | jq -r '.message' 2>/dev/null || echo "$body"
                return 1
            fi

            # 获取新创建的群组ID
            parent_id=$(echo "$body" | jq -r '.id')
            if [ "$parent_id" == "null" ] || [ -z "$parent_id" ]; then
                error "无法获取新创建群组的ID: $current"
                return 1
            fi
        else
            # 获取已存在群组的ID
            local encoded=$(urlencode "$current")
            local group_info=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
                "$SLAVE_URL/api/v4/groups/$encoded")
            parent_id=$(echo "$group_info" | jq -r '.id')
        fi
    done

    return 0
}

# 创建项目
create_project_on_slave() {
    local project_path="$1"

    # 检查是否为大仓库
    if [[ -n "${LARGE_PROJECTS_MAP[$project_path]+_}" ]]; then
        warn "跳过大仓库(由 sync_large_project.sh 管理): $project_path"
        return 0
    fi

    # 检查项目是否已存在
    if project_exists_on_slave "$project_path"; then
        log "项目 '$project_path' 已存在"
        # 检查是否需要设置镜像
        if ! project_has_mirror "$project_path"; then
            warn "项目 '$project_path' 存在但未设置镜像,尝试设置..."
            if setup_mirror "$project_path"; then
                log "镜像设置成功: $project_path"
            else
                error "镜像设置失败: $project_path"
                return 1
            fi
        else
            log "项目 '$project_path' 已存在且已设置镜像"
        fi
        return 0
    fi

    local parent_group=$(dirname "$project_path")
    local project_name=$(basename "$project_path")

    # 确保父群组存在
    if [ "$parent_group" != "." ] && ! group_exists_on_slave "$parent_group"; then
        error "父群组 '$parent_group' 不存在,无法创建项目 '$project_path'"
        return 1
    fi

    # 获取父群组ID
    local parent_id=""
    if [ "$parent_group" != "." ]; then
        local parent_encoded=$(urlencode "$parent_group")
        local parent_resp=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
            "$SLAVE_URL/api/v4/groups/$parent_encoded")

        if echo "$parent_resp" | jq -e . >/dev/null 2>&1; then
            parent_id=$(echo "$parent_resp" | jq -r '.id')
            if [ "$parent_id" == "null" ] || [ -z "$parent_id" ]; then
                error "无法获取父群组ID: $parent_group"
                return 1
            fi
        else
            error "父群组不存在或无法访问: $parent_group"
            return 1
        fi
    fi

    # 准备创建项目的数据
    local data="{\"name\":\"$project_name\",\"path\":\"$project_name\""
    if [ -n "$parent_id" ]; then
        data="$data,\"namespace_id\":$parent_id"
    fi
    data="$data}"

    log "创建项目: $project_path"
    local resp=$(curl -s -w "%{http_code}" --request POST \
        --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
        --header "Content-Type: application/json" \
        --data "$data" \
        "$SLAVE_URL/api/v4/projects")

    local code="${resp: -3}"
    local body="${resp%???}"

    if [[ "$code" == "201" ]]; then
        log "项目创建成功: $project_path"

        # 设置镜像
        if setup_mirror "$project_path"; then
            log "镜像设置成功: $project_path"
        else
            error "镜像设置失败: $project_path"
            return 1
        fi
        return 0
    elif [[ "$code" == "400" ]]; then
        warn "项目 '$project_path' 可能已存在(HTTP 400)"
        # 检查是否需要设置镜像
        if ! project_has_mirror "$project_path"; then
            warn "尝试为已存在的项目设置镜像: $project_path"
            if setup_mirror "$project_path"; then
                log "镜像设置成功: $project_path"
            else
                error "镜像设置失败: $project_path"
                return 1
            fi
        fi
        return 0
    else
        error "创建项目失败: $project_path (HTTP $code)"
        echo "$body" | jq -r '.message' 2>/dev/null || echo "$body"
        return 1
    fi
}

# 设置镜像
setup_mirror() {
    local project_path="$1"
    local encoded=$(urlencode "$project_path")
    local import_url="http://$MASTER_USERNAME:$MASTER_TOKEN@$MASTER_URL/$project_path.git"

    log "为项目设置镜像: $project_path"

    local resp=$(curl -s -w "%{http_code}" --request PUT \
        --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
        --header "Content-Type: application/json" \
        --data "{
            \"mirror\": true,
            \"import_url\": \"$import_url\",
            \"mirror_direction\": \"pull\",
            \"mirror_trigger_builds\": false,
            \"mirror_interval\": \"30m\"
        }" \
        "$SLAVE_URL/api/v4/projects/$encoded")

    local code="${resp: -3}"

    if [[ "$code" =~ ^(200|201|202)$ ]]; then
        log "镜像设置成功: $project_path"

        log "更新项目设置: 添加覆盖参数 $project_path"
        PROJECT_NAME="${project_path##*/}"
        log $PROJECT_NAME
        UPDATE_RESP=$(curl -s --request PUT \
          --header "PRIVATE-TOKEN: $SLAVE_TOKEN" \
          --header "Content-Type: application/json" \
          --data "{\"name\":\"$PROJECT_NAME\",\"mirror\":true,\"mirror_overwrites_diverged_branches\":true}" \
          "$SLAVE_URL/api/v4/projects/$encoded")

       # 检查更新是否成功
       if echo "$UPDATE_RESP" | jq -e '.message // .error' > /dev/null 2>&1; then
         echo "更新失败: $(echo "$UPDATE_RESP" | jq -r '."message" // .error // "."')"
         continue
       fi

      # 验证配置
      VERIFY_DATA=$(curl -s --header "PRIVATE-TOKEN: $SLAVE_TOKEN" "$SLAVE_URL/api/v4/projects/$encoded")

       if echo "$VERIFY_DATA" | jq -e '.name' > /dev/null 2>&1; then
          VERIFY=$(echo "$VERIFY_DATA" | jq -r '[.name, .mirror, .mirror_overwrites_diverged_branches] | @tsv')
          echo "配置结果 (name | mirror | overwrite): $VERIFY"
       else
          echo "无法验证配置"
       fi

       log "镜像和覆盖已经设置完成"
        return 0
    else
        error "镜像设置失败: $project_path (HTTP $code)"
        return 1
    fi

}

# 判断项目是否为空
is_project_empty() {
    local project_path="$1"
    local encoded=$(urlencode "$project_path")

    local resp=$(curl -s --header "PRIVATE-TOKEN: $MASTER_TOKEN" \
        "$MASTER_URL/api/v4/projects/$encoded")

    if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
        return 1
    fi

    local default_branch=$(echo "$resp" | jq -r '.default_branch')
    if [ -z "$default_branch" ] || [ "$default_branch" == "null" ]; then
        return 0  # 没有默认分支,认为是空项目
    fi

    local commits_url="$MASTER_URL/api/v4/projects/$encoded/repository/commits?ref_name=$default_branch&per_page=1"
    local commit_count=$(curl -s --header "PRIVATE-TOKEN: $MASTER_TOKEN" "$commits_url" | jq -r 'length')

    [ "$commit_count" -eq 0 ]
}

# 主函数
main() {
    log "开始时间: $(date)"
    echo ""

    # 初始化任务数组
    declare -a root_groups_to_create=()
    declare -a subgroups_to_create=()
    declare -a projects_to_create=()
    declare -a projects_to_setup_mirror=()

    # 处理每个根群组
    for root_group in "${ROOT_GROUPS[@]}"; do
        if [ -z "$root_group" ]; then
            continue
        fi

        log "========== 处理根群组: $root_group =========="

        # 获取主GitLab数据(递归获取所有群组和项目)
        info "获取主GitLab数据..."
        local master_groups_json=$(get_all_groups_recursive "$root_group" "$MASTER_TOKEN" "$MASTER_URL" "master")
        if [ $? -ne 0 ]; then
            error "获取主GitLab数据失败,跳过根群组: $root_group"
            continue
        fi

        local master_projects_json=$(get_all_projects_recursive "$root_group" "$MASTER_TOKEN" "$MASTER_URL" "master")
        if [ $? -ne 0 ]; then
            error "获取主GitLab项目数据失败,跳过根群组: $root_group"
            continue
        fi

        # 统计主GitLab数据
        local master_group_count=$(echo "$master_groups_json" | jq 'length')
        local master_project_count=$(echo "$master_projects_json" | jq 'length')
        info "主GitLab - 找到 ${master_group_count}个群组(包含根群组),${master_project_count}个项目"

        # 获取从GitLab数据(递归获取所有群组和项目)
        info "获取从GitLab数据..."
        local slave_groups_json=$(get_all_groups_recursive "$root_group" "$SLAVE_TOKEN" "$SLAVE_URL" "slave")
        local slave_projects_json=$(get_all_projects_recursive "$root_group" "$SLAVE_TOKEN" "$SLAVE_URL" "slave")

        # 检查返回的数据是否是有效的json
        if ! echo "$slave_groups_json" | jq -e . >/dev/null 2>&1; then
                warn "从Gitlab获取的群组数据无效,使用空数组代替(从Gitlab根群组可能不存在)"
                slave_groups_json="[]"
        fi
        if ! echo "$slave_projects_json" | jq -e . >/dev/null 2>&1; then
                warn "从Gitlab获取的项目数据无效,使用空数组代替"
                slave_projects_json="[]"
        fi

        # 统计从GitLab数据
        local slave_group_count=$(echo "$slave_groups_json" | jq 'length')
        local slave_project_count=$(echo "$slave_projects_json" | jq 'length')
        info "从GitLab - 找到 ${slave_group_count}个群组(包含根群组),${slave_project_count}个项目"

        # 从JSON中提取群组路径
        declare -a master_groups=()
        while IFS= read -r line; do
            [ -n "$line" ] && master_groups+=("$line")
        done < <(echo "$master_groups_json" | jq -r '.[] | .full_path // .path' 2>/dev/null)

        # 从JSON中提取项目路径
        declare -a master_projects=()
        while IFS= read -r line; do
            [ -n "$line" ] && master_projects+=("$line")
        done < <(echo "$master_projects_json" | jq -r '.[] | .path_with_namespace' 2>/dev/null)

        # 从JSON中提取从GitLab群组路径
        declare -a slave_groups=()
        while IFS= read -r line; do
            [ -n "$line" ] && slave_groups+=("$line")
        done < <(echo "$slave_groups_json" | jq -r '.[] | .full_path // .path' 2>/dev/null)

        # 对比根群组(通过API实时检查)
        if ! group_exists_on_slave "$root_group"; then
            root_groups_to_create+=("$root_group")
        fi

        # 对比子群组(排除根群组)
        for group in "${master_groups[@]}"; do
            if [ "$group" != "$root_group" ]; then
                # 检查群组是否在从GitLab存在(通过API实时检查)
                if ! group_exists_on_slave "$group"; then
                    subgroups_to_create+=("$group")
                fi
            fi
        done

        # 对比项目
        for project in "${master_projects[@]}"; do
            # 跳过大仓库
            if [[ -n "${LARGE_PROJECTS_MAP[$project]+_}" ]]; then
                warn "跳过大仓库: $project"
                continue
            fi

            # 检查项目是否在从GitLab存在(通过API实时检查)
            if project_exists_on_slave "$project"; then
                # 项目存在,检查是否需要设置镜像
                if ! project_has_mirror "$project"; then
                    projects_to_setup_mirror+=("$project")
                fi
            else
                # 项目不存在
                projects_to_create+=("$project")
            fi
        done

        echo ""
    done

    # 去重和排序
    root_groups_to_create=($(printf "%s\n" "${root_groups_to_create[@]}" | sort -u))
    subgroups_to_create=($(printf "%s\n" "${subgroups_to_create[@]}" | sort -u))
    projects_to_create=($(printf "%s\n" "${projects_to_create[@]}" | sort -u))
    projects_to_setup_mirror=($(printf "%s\n" "${projects_to_setup_mirror[@]}" | sort -u))

    # 子群组按路径深度排序(确保父群组先创建)
    if [ ${#subgroups_to_create[@]} -gt 0 ]; then
        subgroups_to_create=($(printf "%s\n" "${subgroups_to_create[@]}" | awk -F/ '{print NF, $0}' | sort -n | cut -d' ' -f2-))
    fi

    # 显示任务清单
    log "========== 任务清单 =========="
    echo ""

    if [ ${#root_groups_to_create[@]} -gt 0 ]; then
        log "1. 需创建的根群组: ${#root_groups_to_create[@]}个"
        for group in "${root_groups_to_create[@]}"; do
            echo "   - $group"
        done
        echo ""
    else
        log "1. 需创建的根群组: 0个 (所有根群组已存在)"
        echo ""
    fi

    if [ ${#subgroups_to_create[@]} -gt 0 ]; then
        log "2. 需创建的子群组: ${#subgroups_to_create[@]}个"
        for group in "${subgroups_to_create[@]}"; do
            echo "   - $group"
        done
        echo ""
    else
        log "2. 需创建的子群组: 0个 (所有子群组已存在)"
        echo ""
    fi

    if [ ${#projects_to_create[@]} -gt 0 ]; then
        log "3. 需创建并同步的项目: ${#projects_to_create[@]}个"
        for project in "${projects_to_create[@]}"; do
            echo "   - $project"
        done
        echo ""
    else
        log "3. 需创建并同步的项目: 0个 (所有项目已存在)"
        echo ""
    fi

    if [ ${#projects_to_setup_mirror[@]} -gt 0 ]; then
        log "4. 需设置镜像的已有项目: ${#projects_to_setup_mirror[@]}个"
        for project in "${projects_to_setup_mirror[@]}"; do
            echo "   - $project"
        done
        echo ""
    else
        log "4. 需设置镜像的已有项目: 0个 (所有项目镜像已设置)"
        echo ""
    fi

    local total_tasks=$((${#root_groups_to_create[@]} + ${#subgroups_to_create[@]} + ${#projects_to_create[@]} + ${#projects_to_setup_mirror[@]}))

    if [ $total_tasks -eq 0 ]; then
        log "所有内容已同步,无需操作!"
        log "结束时间: $(date)"
        exit 0
    fi

    warn "10秒后将开始执行上述任务... (按 Ctrl+C 取消)"
    for i in {10..1}; do
        echo -ne "倒计时: ${i}\033[0K\r"
        sleep 1
    done
    echo ""

    # 执行任务
    log "========== 开始执行任务 =========="
    echo ""

    # 1. 创建根群组
    if [ ${#root_groups_to_create[@]} -gt 0 ]; then
        log ">>> 创建根群组"
        for group in "${root_groups_to_create[@]}"; do
            if ! create_group_on_slave "$group"; then
                error "创建根群组失败: $group"
                # 继续执行其他任务
            fi
        done
        echo ""
    fi

    # 2. 创建子群组
    if [ ${#subgroups_to_create[@]} -gt 0 ]; then
        log ">>> 创建子群组"
        for group in "${subgroups_to_create[@]}"; do
            if ! create_group_on_slave "$group"; then
                error "创建子群组失败: $group"
                # 继续执行其他任务
            fi
        done
        echo ""
    fi

    # 3. 创建项目
    if [ ${#projects_to_create[@]} -gt 0 ]; then
        log ">>> 创建项目"
        local success_count=0
        local fail_count=0

        for project in "${projects_to_create[@]}"; do
            # 检查项目是否为空
            if is_project_empty "$project"; then
                warn "项目 '$project' 为空(无提交),跳过创建"
                continue
            fi

            if create_project_on_slave "$project"; then
                ((success_count++))
            else
                ((fail_count++))
            fi
        done

        log "项目创建完成: 成功 $success_count 个, 失败 $fail_count 个"
        echo ""
    fi

    # 4. 为已有项目设置镜像
    if [ ${#projects_to_setup_mirror[@]} -gt 0 ]; then
        log ">>> 为已有项目设置镜像"
        local success_count=0
        local fail_count=0

        for project in "${projects_to_setup_mirror[@]}"; do
            if setup_mirror "$project"; then
                ((success_count++))
            else
                ((fail_count++))
            fi
        done

        log "镜像设置完成: 成功 $success_count 个, 失败 $fail_count 个"
        echo ""
    fi

    log "========== 任务执行完成 =========="
    log "结束时间: $(date)"
}

# 捕获Ctrl+C信号
trap 'echo -e "\n\n操作已被用户取消"; exit 1' INT

# 运行主函数
main "$@"

2.拉取大仓库的脚本

#!/bin/bash
# 这个脚本是用来通过git clone / git push 同步主Gitlab上多个仓库的。
# 原因是:个别仓库太大了,不适合通过Gitlab的WEB界面自带的“镜像仓库”功能去同步。(一个是速度慢,另外一个是容易超时)
# 所以个别大的仓库我采用了 底层的git clone / git push来同步到【从Gitlab】上。(速度快,不受限制)
# 然后再通过定时任务,每30分钟更新一次代码。(此频率同“镜像仓库”的频率)

# 同步大仓库:使用 bare 克隆 + --all/--tags 推送

# ================== 配置区 ==================
PROJECTS=(
   "modelzoo/ai-npu/detection"
   "modelzoo/ai-npu/mmlab"
)


MASTER_HOST="mastergitlab.com"
MASTER_PORT="222"

SLAVE_HOST="slavegitlab.com"
SLAVE_PORT="2211"

WORK_DIR="/data/docker-compose/gitlab/sync_script/gitlab-large-project"  # 存放拉取下来的代码。
LOG_FILE="/data/docker-compose/gitlab/sync_script/sync_large_project.log" # 存放日志

SSH_KEY="/root/.ssh/gitlab_sync_key"   # 配置拉取的ssh 密钥,配置在【主Gitlab】

MAX_LOG_SIZE=10485760   # 10MB
MAX_BACKUPS=5

LARGE_PROJECTS_FILE="/data/docker-compose/gitlab/sync_script/large_projects.txt"   # 大仓库的名称。会写入到这个文件里,给上面的脚本提供参考,跳过配置镜像仓库用的。
# 颜色配置

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'  # No Color (重置颜色)



# ==========================================


# --- 日志轮转 ---
rotate_log() {
  if [ -f "$LOG_FILE" ]; then
    local size=$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)
    if [ "$size" -gt "$MAX_LOG_SIZE" ]; then
      for ((i=MAX_BACKUPS; i>=2; i--)); do
        [ -f "${LOG_FILE}.${i}" ] && mv "${LOG_FILE}.${i}" "${LOG_FILE}.$((i+1))"
      done
      mv "$LOG_FILE" "${LOG_FILE}.1"
      rm -f "${LOG_FILE}.$((MAX_BACKUPS+1))"
    fi
  fi
}

rotate_log
echo -e "${BLUE}[$(date)] 开始同步(bare 模式)...${NC}" >> "$LOG_FILE"

if [ ! -f "$SSH_KEY" ]; then
  echo -e "${RED}[$(date)] 错误:SSH 私钥不存在: $SSH_KEY${NC}" >> "$LOG_FILE"
  exit 1
fi

export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o StrictHostKeyChecking=no"

for project in "${PROJECTS[@]}"; do
  echo -e "${BLUE}[$(date)] 同步项目: $project${NC}" >> "$LOG_FILE"

  MASTER_REPO_URL="ssh://git@$MASTER_HOST:$MASTER_PORT/$project.git"
  SLAVE_REPO_URL="ssh://git@$SLAVE_HOST:$SLAVE_PORT/$project.git"
  LOCAL_REPO="$WORK_DIR/${project//\//_}.git"

  if [ ! -d "$LOCAL_REPO" ]; then
    echo -e "${BLUE}首次 bare 克隆...${NC}" >> "$LOG_FILE"

    if ! git clone --bare "$MASTER_REPO_URL" "$LOCAL_REPO"; then
      echo -e "${RED}首次克隆失败: $project ${NC}" >> "$LOG_FILE"
      continue
    fi
    if [ ! -f "$LARGE_PROJECTS_FILE" ];then
       touch "$LARGE_PROJECTS_FILE"
    fi

    if ! grep -q "^$project$" "$LARGE_PROJECTS_FILE" 2>/dev/null; then
        echo "$project" >> "$LARGE_PROJECTS_FILE"
        echo -e "${GREEN}已将大仓库 '$project' 写入 $LARGE_PROJECTS_FILE${NC}" >> "$LOG_FILE"
    else
        echo -e "${YELLOW}大仓库 '$project' 已存在,无需重复写入${NC}" >> "$LOG_FILE"
    fi
  else
    echo -e "${BLUE}更新 bare 仓库...${NC}" >> "$LOG_FILE"
    cd "$LOCAL_REPO" || { echo -e "${RED}无法进入目录: $LOCAL_REPO${NC}" >> "$LOG_FILE"; continue; }
    git remote set-url origin "$MASTER_REPO_URL"
    # 清除可能的 mirror 标记
    git config --unset remote.origin.mirror 2>/dev/null
    if ! git fetch --prune; then
      echo -e "${RED}更新失败: $project${NC}" >> "$LOG_FILE"
      continue
    fi
  fi

  cd "$LOCAL_REPO" || continue
  git remote set-url --push origin "$SLAVE_REPO_URL"
  if git push --all origin && git push --tags origin; then
    echo -e "${GREEN}>>>>[同步完成]: $project${NC}" >> "$LOG_FILE"
  else
    echo -e "${RED}推送失败: $project${NC}" >> "$LOG_FILE"
  fi
done

echo -e "${GREEN}[$(date)] 所有项目同步完成(bare 模式)${NC}" >> "$LOG_FILE"
echo "#########################################################" >> "${LOG_FILE}"

3.Python Webhook 服务,实现实时同步

将这个服务注册到systemd了,具体配置如下;

vim /etc/systemd/system/gitlab-sync.service
[Unit]
Description=GitLab Sync Service
After=network.target

[Service]
ExecStart=/usr/bin/python3 /data/docker-compose/gitlab/sync_script/gitlab_sync_service.py
Restart=always
RestartSec=3
User=root
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

配置区
配置【从Gitlab】的地址和token即可,还是用之前的 rsync 的token就行

# sync_service_pro.py
# 通过检测主Gitlab是否有push、merger等操作,从而获取主Gitlab具体的项目绝对路径,然后去【从Gitlab】相同的路径下触发一次【镜像仓库】的pull操作。
# 也相当于实时同步了
# 之前是30分钟,比较漫长,现在是几乎实时触发同步pull
# 写到systemd里了,启动暂停等操作,可以用systemctl完成
# systemctl enable gitlab-sync
#systemctl  start gitlab-sync
# 日志存放在/var/log/gitlab-sync.log

from flask import Flask, request
import threading
import queue
import requests
import urllib.parse
import time
import logging
from logging.handlers import RotatingFileHandler

app = Flask(__name__)

# ===== 配置 =====
GITLAB_URL = "http://slavegitlab.com"
TOKEN = "xxxxxxxx"
### 日志配置轮转#######
LOG_FILE = "/var/log/gitlab-sync.log"
handler = RotatingFileHandler(LOG_FILE, maxBytes=100*1024*1024, backupCount=5)
formatter = logging.Formatter(
    '%(asctime)s [%(levelname)s] %(message)s'
)
handler.setFormatter(formatter)

logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.addHandler(handler)

############################################
MAX_WORKERS = 10          # 并发数(建议 5~15)
SYNC_INTERVAL = 60        # 同一项目最小间隔(秒)
REQUEST_TIMEOUT = 10
RETRY_TIMES = 3

# ===== 队列 =====
task_queue = queue.Queue(maxsize=10000)

# ===== 去重(带时间)=====
recent = {}

lock = threading.Lock()

def should_sync(path):
    now = time.time()

    with lock:
        last = recent.get(path, 0)

        if now - last < SYNC_INTERVAL:
            return False

        recent[path] = now
        return True

# ===== webhook =====
@app.route("/webhook", methods=["POST"])
def webhook():
    data = request.json

    path = data.get("project", {}).get("path_with_namespace")

    if not path:
        return "ok"

    # 提取事件类型
    event_type = data.get("object_kind", "unknown")
    # 对merge_request 事件,进一步区分是opend merged
    if event_type == "merge_request":
        action = data.get("object_attributes", {}).get("action", "unknown")
        event_type = f"merge_request_{action}"

    if should_sync(path):
        encoded = urllib.parse.quote_plus(path)

        try:
            # 将事件类型一起入队
            task_queue.put_nowait((encoded,event_type))
            logger.info(f"[入队] {path} 事件类型: {event_type}")
        except queue.Full:
            logger.info(f"[丢弃] 队列满: {path} 事件类型: {event_type}")
    else:
        logger.info(f"[跳过] 防抖: {path} 事件类型: {event_type}")

    return "ok"


# ===== worker =====
def worker(worker_id):
    while True:
        encoded, event_type = task_queue.get()

        success = False
        url = f"{GITLAB_URL}/api/v4/projects/{encoded}/mirror/pull"
        headers = {"PRIVATE-TOKEN": TOKEN}

        for i in range(RETRY_TIMES):
            try:

                r = requests.post(url, headers=headers, timeout=REQUEST_TIMEOUT)

                if r.status_code == 200:
                    logger.info(f"[成功][主动触发同步][W{worker_id}] {encoded} 事件: {event_type}")
                    success = True
                    break
                elif r.status_code == 400:
                    logger.warning(f"[项目未创建][W{worker_id}] {encoded} | WX GitLab尚未创建该项目,等待shell脚本创建(每10分钟运行一次)后续提交将自动同步,不影响!")
                    success = True
                    break
                else:
                    logger.error(f"[失败][主动同步][W{worker_id}] {encoded} {r.status_code} {r.text} 事件: {event_type}")
            except Exception as e:
                logger.info(f"[异常][W{worker_id}] {encoded} {e} 事件: {event_type}")

            time.sleep(2)  # 重试间隔

        if not success:
            logger.info(f"[最终失败] {encoded} 事件: {event_type}")

        task_queue.task_done()





# ===== 启动 worker =====
for i in range(MAX_WORKERS):
    t = threading.Thread(target=worker, args=(i,))
    t.daemon = True
    t.start()


# ===== 启动 =====
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

说一下如何配置这个webhook服务,
正常启动后服务后,tail -f /var/log/gitlab-sync.log 日志文件
会输出 webhook地址,一般是 http://xx.xx.xx.xx:5000/webhook
复制这个地址
rsync用户登录去到【主Gitlab】,然后打开你要设置同步的根群组,例如是roottest,然后进入 设置–》Webhooks
右边出现 地址:填写这个地址, Secrt 令牌不用管,然后勾选触发来源,包括 push merge ,其他你看着勾选即可。
然后点击添加webhooks即可
然后点击测试,测试push events,然后就可以在日志打印出来,是否成功的标识,如果成功就OK了
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值