如何批量修改Jenkins Job配置?

背景

有30多个前端Jenkins Pipeline类型的Job项目, 需要将webhook的配置,从Jenkins自带的webhook类型,修改为通用的webhook类型Generic Webhook Trigger, 为什么要做这样的修改呢,因为Jenkins自带的webhook类型,推送的消息内容不完整,比如想获取gitlab 分支合并请求的label标记,就无法获得。

可是要改成通用的Webhook类型,面临的问题是配置参数很多,如下图所示,每个项目这样的参数有七八个,一屏都展示不全, 逐个改耗时费力。于是打算写一个脚本,批量修改Jenkins Job配置

动手实践

先说结论,总共尝试了三种脚本:

  1. groovy脚本,在Jenkins 服务器的console中运行, 调试了半天, 运行不报错,但修改始终不生效,最后放弃
  2. bash脚本, 修改Jenkins Job时遇到跨域问题, 未能解决,无奈放弃。
  3. Python脚本, 采用Jenkins API修改Jenkins Job配置,一路开发调试比较流畅, 实现了目标。

本文采用事后总结的方式,讲一下Python脚本方案批量修改Jenkins Job的实现过程。

step1 安装Python

Python官网,下载最新的Python,本文选择 的是Windows installer (64-bit)这个安装包,安装时记得勾选 ☑️ Add Python 3.x to PATH(添加到环境变量), 安装完成后,执行下面两条命令, 验证安装是否成功。

js 复制代码
# 查看Python版本
python --version
# 查看Python包管理器版本
pip --version

step2 获取调用Jenkins API的Token

调用Jenkins的API, 需要一个凭据,才能正常调用。 Jenkins API调用凭证的获取方法是:

前提是你得有Jenkins 服务器管理员权限, 否则下面的菜单你看不到。进入系统管理==>全局安全配置

找到 API Token配置项,勾选 为每个新创建的用户生成一个遗留的 API token复选框

接着点击页面右上角显示登录用户名称旁边的下拉箭头,进入 Security子项菜单

进入新页面后,点击添加新Token按钮,再点击输入框旁边的生成按钮,就能生成调用Jenkins API所需的Token

step3 配置一个Jenkins Job修改参照模版

业务要求Jenkins需要监听的Gitlab Webhook事件是:

  1. 每个开发迭代周期创建新的发布分支时
  2. 有特性分支发起合并发布分支请求时

这就需要对Gitlab Webhook推送过来的事件数据进行解析, 判断是否满足条件。

  • Post content parameters表示解析的是请求体部分的数据
  • Header parameters 表示解析的是请求头部分的数据
  • Request parameters表示解析的是请求URL中的查询参数

这里我们用到的是Post content parameters类型,每个变量的配置项有4项,我们以WEBHOOK_JSON(在Jenkins主流程解析webhook推送数据时要用到)为例,说明一下配置变量时每个配置项的含义:

1. Variable

  • 定义一个变量名(图中是 WEBHOOK_JSON),用于接收和存储 webhook 的请求体内容。
  • 这个变量在后续的构建步骤中可以引用,比如作为参数传递给脚本或其他插件。

2. Expression

  • 指定从请求体中提取数据的表达式类型。图中选择的是 JSONPath,意味着会从 JSON 数据中解析目标字段。

  • 用于定位 webhook payload 中具体的字段,比如 $.ref$.commits[*].id 等。

  • JSONPath 通常用于 REST API 返回的 JSON 数据,比如 GitLab webhook payload、Kubernetes config、CI/CD 参数传递。

  • XPath 多用于 XML 文档,如旧版 SOAP 接口、Jenkins Job config.xml、自定义插件配置等。

3. Value Filter

  • 对提取结果应用过滤规则。支持使用正则表达式进行匹配或替换。
  • 示例正则:[^0-9] 表示过滤掉非数字字符,只保留数字。这在提取数字 ID、版本号等时尤其有用。

4. Default Value

  • 如果表达式无法匹配任何字段或值为空时,系统将使用这个默认值作为变量的内容。
  • 能有效防止构建因 webhook 内容异常而失败。

掌握了变量的配置方法之后, 需要依次配置下面五个变量

变量 含义 取webhook推送的值
ref 推送的引用路径,示例值:refs/heads/main $.ref
event_name webhook事件类型标识 $.event_name
before 推送前的 commit SHA,如果 before 是全 40 位 0 值,表示新建分支 $.before
target_branch 合并时的目标分支 $.object_attributes.target_branch
merge_state 合并状态,示例值:opened, merged $.object_attributes.state

现在配置Jenkins Job只处理新建release分支和合并目标为release分支的webhook事件, 配置如下:

下面解释一下每项配置含义:

1. Optional filter

表示这是一个"可选"过滤器,只有当 webhook payload 中的内容满足特定的规则时,才会触发 Job 构建。如果不配置这个过滤器,所有 webhook 请求都会触发构建;配置后会按表达式进行判断。

2. Expression(正则表达式)

refs/heads/release/.* push 0000000000000000000000000000000000000000 | release/.* (opened|merged) 这条正则用于匹配特定的分支、事件类型、提交 ID 和合并状态。 举例说明:

  • refs/heads/release/.* push 0000000000000000000000000000000000000000 是检测是否为 新创建 release 分支事件
  • release/.* (opened|merged) 是检测是否为 release 分支的合并请求打开或合并事件

3. Text

$ref $event_name $before $target_branch $merge_state

用于构造供表达式匹配的实际字符串。会将 webhook 中的变量值依次拼接,比如:

  • $ref: 提交的分支路径,例如 refs/heads/release/v1.2
  • $event_name: webhook 事件类型,如 pushmerge_request
  • $before: 提交前的 SHA 值,常用于检测删除操作(如全 0)
  • $target_branch: 目标分支(用于合并请求)
  • $merge_state: 合并请求状态,如 openedmerged

step4 编写批量修改Jenkins Job脚本

前面已经把实现批量修改JenkinsJob功能的外围障碍扫清了,现在来编写主逻辑功能。主体思路是:

  1. 配置一个模版项目和待批量修改的项目列表
  2. 读取模版项目的Trigger部分Generic Webhook Trigger配置,保存到一个临时变量中
  3. 遍历待批量修改的项目列表,除了Token属性要修改成每个项目的job名称之外,其它参数全部照搬模版,改完之后进行保存提交。
  4. 为了避免改错丢失项目原有配置,改之前需要对原来的配置进行备份
  5. 最后,需要在Gitlab中给每个项目配置webhook,不存在则创建,存在则修改成统一配置

新建replace_trigger.py文件,内容如下:

python 复制代码
import requests
from lxml import etree
import copy
import os
import json


# Jenkins 凭证
JENKINS_USER = "登录名称"
JENKINS_TOKEN = "第二步获取的API-Token"
JENKINS_URL = "http://192.168.10.91:8080"
// 模版job
TEMPLATE_JOB = f"{JENKINS_URL}/view/prod-algorithm/job/prod-algorithm/job/travel-ai/"
HEADERS = {"Content-Type": "application/xml; charset=UTF-8"}

# GitLab 配置
GITLAB_TOKEN = "Gitlab API Token"
GITLAB_URL = "https://git.xxx.com"
WEBHOOK_URL = "http://192.168.10.91:8080/generic-webhook-trigger/invoke?WEBHOOK_DEBUG=N"


# 项目映射:{Jenkins job 路径: GitLab 项目 ID}
target_job_map = {
    "prod-data/proj1": 654,
    // ...
}

def get_crumb():
    url = f"{JENKINS_URL}/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,\":\",//crumb)"
    resp = requests.get(url, auth=(JENKINS_USER, JENKINS_TOKEN))
    parts = resp.text.strip().split(":", 1)
    return {parts[0]: parts[1]}

def fetch_config(url):
    resp = requests.get(f"{url}/config.xml", auth=(JENKINS_USER, JENKINS_TOKEN))
    xml = resp.text.replace("<?xml version='1.1'", "<?xml version='1.0'")
    return etree.fromstring(xml.encode("utf-8"))

def extract_pipeline_trigger_property(template_tree, token_value,job_path):
    props = template_tree.find("properties")
    if props is None:
        return None
    pipeline_prop = props.find("org.jenkinsci.plugins.workflow.job.properties.PipelineTriggersJobProperty")
    if pipeline_prop is None:
        return None
    cloned = copy.deepcopy(pipeline_prop)
    for token in cloned.findall(".//token"):
        token.text = token_value
    # if job_path.startswith("prod-"):
    #     for regexpFilterExpression in cloned.findall(".//regexpFilterText"):
    #         regexpFilterExpression.text = "$merge_state $target_branch"
    #     for regexpFilterExpression in cloned.findall(".//regexpFilterExpression"):
    #         regexpFilterExpression.text = "^(opened|merged) release/.*$"
    return cloned

def replace_trigger_in_target(target_tree, new_prop):
    if new_prop is None:
        return
    props = target_tree.find("properties")
    if props is None:
        props = etree.Element("properties")
        target_tree.insert(0, props)
    existing = props.find("org.jenkinsci.plugins.workflow.job.properties.PipelineTriggersJobProperty")
    if existing is not None:
        props.remove(existing)
    props.append(new_prop)

def extract_definition(template_tree):
    original_def = template_tree.find("definition")
    if original_def is None:
        return None
    return copy.deepcopy(original_def)

def replace_definition_in_target(target_tree, new_def):
    if new_def is None:
        return
    existing_def = target_tree.find("definition")
    if existing_def is not None:
        target_tree.remove(existing_def)
    target_tree.append(new_def)

def upload_config(job_url, xml_str, crumb):
    resp = requests.post(f"{job_url}/config.xml",
                         auth=(JENKINS_USER, JENKINS_TOKEN),
                         headers={**HEADERS, **crumb},
                         data=xml_str.encode("utf-8"))
    print(f"🔧 Jenkins 上传 → {job_url}/config.xml → 状态码:{resp.status_code}")

def webhook_exists(project_id, webhook_url):
    url = f"{GITLAB_URL}/api/v4/projects/{project_id}/hooks"
    headers = {"PRIVATE-TOKEN": GITLAB_TOKEN}
    try:
        resp = requests.get(url, headers=headers)
        if resp.status_code == 200:
            for hook in resp.json():
                if hook.get("url") == webhook_url:
                    hook_id = hook.get("id")
                    print(f"🔍 webhook 已存在(ID: {hook_id}):{webhook_url}")
                    return hook_id
        else:
            print(f"⚠️ 获取 webhook 列表失败:{resp.status_code}")
    except Exception as e:
        print(f"❌ 获取 webhook 时出错:{e}")
    return None


def ensure_gitlab_webhook(project_id, job_name):
    hook_id = webhook_exists(project_id, WEBHOOK_URL)
    
    if hook_id:
        # webhook 已存在,执行更新
        method = "put"
        url = f"{GITLAB_URL}/api/v4/projects/{project_id}/hooks/{hook_id}"
        action = "更新"
    else:
        # webhook 不存在,执行创建
        method = "post"
        url = f"{GITLAB_URL}/api/v4/projects/{project_id}/hooks"
        action = "创建"

    payload = {
        "url": WEBHOOK_URL,
        "push_events": True,
        "push_events_branch_filter": "release*",
        "merge_requests_events": True,
        "token": job_name
    }
    headers = {
        "PRIVATE-TOKEN": GITLAB_TOKEN,
        "Content-Type": "application/json"
    }

    try:
        resp = getattr(requests, method)(url, headers=headers, json=payload)
        if resp.status_code in [200, 201]:
            print(f"✅ 成功{action} GitLab webhook:{WEBHOOK_URL}")
        else:
            print(f"❌ {action} webhook 失败,状态码:{resp.status_code}")
            print(resp.text)
    except Exception as e:
        print(f"❌ {action} webhook 时出错:{e}")

def job_url_from_path(job_path):
    return f"{JENKINS_URL}/job/" + "/job/".join(job_path.split("/"))

def job_name_from_path(job_path):
    return job_path.split("/")[-1]

def main():
    print("🚀 批量配置 Jenkins Job + GitLab webhook ...")
    crumb = get_crumb()
    print("✅ Jenkins crumb 获取成功")
    template_tree = fetch_config(TEMPLATE_JOB)
    print("📥 模版配置加载完成")

    for job_path, project_id in target_job_map.items():
        job_url = job_url_from_path(job_path)
        job_name = job_name_from_path(job_path)
        print(f"\n🔄 正在处理 Jenkins Job:{job_path} → GitLab 项目 ID:{project_id}")

        try:
            target_tree = fetch_config(job_url)
            print("📥 Jenkins Job 配置已加载")

            # 备份
            backup_str = etree.tostring(target_tree, encoding="unicode", pretty_print=True)
            backup_str = "<?xml version='1.0' encoding='UTF-8'?>\n" + backup_str
            backup_file_name = f"prod_backup_{job_name}.xml" if 'prod' in job_path else f"backup_{job_name}.xml"

            with open(backup_file_name, "w", encoding="utf-8") as f:
                f.write(backup_str)
            print(f"📦 备份完成:{backup_file_name}")

            # 替换触发器与脚本
            new_prop = extract_pipeline_trigger_property(template_tree, job_name,job_path)
            if new_prop is not None and len(new_prop):
                replace_trigger_in_target(target_tree, new_prop)
                print("🔧 Jenkins Trigger 替换完成")

            new_def = extract_definition(template_tree)
            if new_def is not None and len(new_def):
                replace_definition_in_target(target_tree, new_def)
                print("📄 Jenkins Definition 替换完成")

            # 保存修改后的 XML
            updated_str = etree.tostring(target_tree, encoding="unicode", pretty_print=True)
            updated_str = "<?xml version='1.0' encoding='UTF-8'?>\n" + updated_str
            modified_file_name = f"prod_modified_{job_name}.xml" if 'prod' in job_path else f"modified_{job_name}.xml"
             with open(modified_file_name, "w", encoding="utf-8") as f:
                 f.write(updated_str)
             print(f"✅ 修改保存:{modified_file_name}")

            # 上传到 Jenkins
            upload_config(job_url, updated_str, crumb)

            # 创建 GitLab webhook
            create_gitlab_webhook(project_id, job_name)

        except Exception as e:
            print(f"❌ 错误处理 Job {job_path}:{e}")

if __name__ == "__main__":
    main()

由于脚本中用到了一些第三方依赖,所以创建一个requirements.txt文件,管理项目所需的第三方依赖库。写入内容:

js 复制代码
requests
lxml

执行安装命令,进行依赖的安装

bash 复制代码
pip install -r requirements.txt

然后执行:

js 复制代码
python replace_trigger.py

执行完之后,登录Jenkins和Gitlab查看修改是否生效,笔者看到修改均生效。

最后

在日常的系统运维与持续集成实践中,我们经常面对大量重复性的配置修改、接口调用及数据变更。这类工作耗时、易错,且严重制约了团队的效率与响应速度。与其每次都手动修改,不如彻底转变思维方式:让脚本成为你最可靠的助手。通过将操作流程标准化、脚本化,不仅能避免人为疏漏,还能快速适配环境变化与需求迭代。自动化脚本既是技术沉淀的体现,也是架构可靠性与可维护性的重要保障。当你开始习惯用代码解决问题,你会发现,那些原本繁琐重复的工作已悄然化为一行行优雅的逻辑。最终,我们不只是写脚本去省事,更是在构建一个更稳定、更智能、更可预期的技术生态------这是每一位技术人应有的追求,也体现了技术人的核心价值。

相关推荐
北京_宏哥9 分钟前
《刚刚问世》系列初窥篇-Java+Playwright自动化测试-30- 操作单选和多选按钮 - 番外篇(详细教程)
java·前端·测试
leslie04039 分钟前
从零构建微前端生态平台:基于 Module Federation 的最佳实践
前端
筷子夹豆腐9 分钟前
Vue3 解决大屏自适应(缩放)解决方案
前端
Qinana10 分钟前
🚀 用代码搭建「心情小窝」:一个极简情绪记录工具的实现 📝
前端·javascript
可乐202711 分钟前
为什么监听数据变化 页面才会出现内容
前端
isixe12 分钟前
uniapp 兼容 H5 滚动
前端·vue.js
JarvanMo12 分钟前
🪦 话说……Flutter 真要凉了吗?那些瞎吵吵的背后到底咋回事儿?
前端
张志鹏PHP全栈13 分钟前
Vue3第八天,watch监听函数
前端·vue.js
司宸16 分钟前
学习笔记二
前端
讨厌吃蛋黄酥18 分钟前
#Zustand:轻量级状态管理的革命,告别Context与Reducer的痛点!
前端·javascript·react.js