跳过正文
  1. 博客文章/

Jenkins 动态参数流水线:模块版本选择、GitLab 触发与结果回填

·785 字·4 分钟·
DevOps CI/CD Jenkins Active Choices GitLab Pipeline Groovy 动态参数
Zayn
作者
Zayn
专注 Kubernetes、CI/CD、可观测性等云原生技术栈,记录生产环境中的实战经验与踩坑复盘。
目录
这条流水线最初看起来只是参数多:选目标项目,再给几个模块选版本。真正麻烦的是参数之间有依赖,版本列表来自远端 API,最后还要把选择结果交给 GitLab,并让 Jenkins 等到下游结束。参数页一旦失灵,构建人员连任务都发不出去;校验放松一点,错误版本又会一路传到下游。

本文根据一份实际使用的统一构建 Jenkinsfile 整理,复核基线为 commit b133caae。内部域名、项目 ID、凭据 ID、模块名和制品地址均已泛化。公开示例是重新实现,不复制内部共享库源码。

完整 demo 放在 cdryzun/jenkins-dynamic-parameters-demo。仓库默认 DRY_RUN=true,自带本地版本 API、共享库步骤、测试和 GitHub Actions。

参数之间有依赖
#

这份任务要收集三类输入:

输入来源约束
目标项目Jenkinsfile 中的项目映射用户只能选别名,不能直接填项目 ID
模块版本类型Active Choices 单选项只接受 releasecommit
模块版本版本 API列表随模块和版本类型变化

静态 choice 可以写死几个选项,但版本会持续增加。把版本号继续塞进 Jenkinsfile,意味着每发一个版本都要改流水线。改成自由文本也不合适,拼写错误要到下游构建后才暴露。

因此我保留了两级选择:先选版本类型,再从 API 返回的数据里筛版本。目标项目仍是静态别名,映射到哪个 GitLab 项目由受信代码决定。

最后保留的链路
#

flowchart LR
    User["Build with Parameters"] --> Choices["Active Choices 参数"]
    Choices -->|"连接/读取各 3 秒"| VersionAPI["版本 API"]
    Choices --> Validate["Pipeline 二次校验"]
    Validate --> Vars["白名单变量"]
    Vars --> DryRun{"DRY_RUN"}
    DryRun -->|"true"| Summary["仅输出汇总"]
    DryRun -->|"false"| GitLab["触发 GitLab Pipeline"]
    GitLab --> Poll["轮询终态"]
    Poll --> Result["回填 Jenkins 结果"]

参数页只负责把候选值呈现出来。构建开始后,Pipeline 会重新验证所有输入,然后按固定规则生成下游变量。GitLab 地址、项目 ID、分支和凭据 ID 不经过表单。

参数定义只负责交互
#

Jenkinsfile 先声明静态参数,再拼上共享库生成的模块参数:

properties([
    parameters([
        choice(
            name: 'TARGET_PROJECT',
            choices: projectMap.keySet().sort(),
            description: 'Downstream project'
        ),
        booleanParam(
            name: 'DRY_RUN',
            defaultValue: true,
            description: 'Validate without triggering GitLab'
        )
    ] + dynamicModuleParameters(
        modules: modules,
        versionApiUrl: trusted.versionApiUrl,
        paramPrefix: 'DEMO_'
    ))
])

模块配置只放受信数据:

def modules = [
    [name: 'core', displayName: 'Core module'],
    [name: 'media', displayName: 'Media module'],
    [name: 'utils', displayName: 'Utilities module']
]

共享库会为每个模块生成两个参数:

DEMO_CORE_VERSION_TYPE
DEMO_CORE_VERSION

前缀把这组参数和 Job 里的其他变量隔开。模块名还要做两次去重检查:原始名称不能重复,规范化后的参数标识也不能冲突。例如 foo-barfoo_bar 最终都会变成 FOO_BAR,应该在配置加载时直接拒绝。

版本 API 要有短超时
#

Active Choices 的 Groovy 脚本运行在 Jenkins Controller。这里不是普通构建节点,慢请求会直接拖住参数页。demo 将连接超时和读取超时分别设为 3 秒;两者不是整次请求的总时限,DNS 解析也可能增加等待时间。目标是让故障尽快返回,而不是保证请求恰好在 3 秒内结束。

def connection
try {
    connection = endpoint.openConnection()
    connection.connectTimeout = 3000
    connection.readTimeout = 3000
    connection.setRequestProperty('Accept', 'application/json')

    if (connection.responseCode != 200) {
        return ['ERROR: VERSION_API_HTTP_' + connection.responseCode]
    }

    def payload = new JsonSlurper().parse(connection.inputStream)
    if (!(payload instanceof Map) || !(payload.versions instanceof List)) {
        return ['ERROR: VERSION_API_SCHEMA']
    }

    def versions = payload.versions
        .findAll { item ->
            item instanceof Map && item.type == selectedType && item.version != null
        }
        .collect { it.version.toString() }
        .findAll { it ==~ /^[A-Za-z0-9][A-Za-z0-9._+\/@:-]{0,127}$/ }
        .unique()
        .take(100)

    return versions ?: ['ERROR: NO_MATCHING_VERSIONS']
} catch (Exception ignored) {
    return ['ERROR: VERSION_API_UNAVAILABLE']
} finally {
    if (connection != null) {
        connection.disconnect()
    }
}

我没有在 fallback 里放一个看似可用的假版本。API 失效时,参数页只显示 ERROR: 哨兵,后面的验证阶段会拒绝它。这样失败发生在下游凭据绑定之前,也不会把 v1.0.0 之类的占位值误当成真实版本。

API 返回值也要收口。demo 每个模块最多接收 100 个去重版本,版本字符串最长 128 个字符,只允许版本号、短 commit 和常见引用字符。错误消息不回显异常正文,避免把内部 URL 或代理信息带到参数页。

生产环境应使用固定的 HTTPS 地址,并通过防火墙或代理限制 Jenkins Controller 的出口。地址来自受版本控制的配置,不能拼接用户提供的主机名。

第一轮构建为什么看不到参数
#

properties() 是在流水线运行时更新 Job 配置。新建 Job 后第一次点击 Build,常见结果是参数刚被安装,这次构建却拿不到完整的 params

我在验证阶段保留了明确提示:

if (params.DRY_RUN == null) {
    error('Run the job again with Build with Parameters; this build installed the parameters')
}

实际操作顺序是:

  1. 先运行一次,让 Jenkins 写入参数定义;
  2. 回到 Job 页面,打开 Build with Parameters
  3. 选择项目和各模块版本,再开始正式构建。

这不是 Active Choices 的网络问题。排查参数页时,先确认 Job 是否完成过第一次配置刷新,可以省掉不少无效调试。

通过 REST API 触发 Jenkins 时,调用方也不会自动获得参数页的级联交互。它必须传齐最终参数,Pipeline 的二次校验负责兜底。

Pipeline 仍要重新校验
#

参数页的值不能直接传给 GitLab。用户可以通过 Jenkins API 发起构建,版本 API 的数据也可能在页面打开后变化。Pipeline 至少要检查:

  • 版本类型是否为 releasecommit
  • 版本是否为空或以 ERROR: 开头;
  • 版本长度和字符集是否符合约定;
  • 目标项目是否存在于受信映射。
selections = validateModuleSelection(
    modules: modules,
    values: params,
    paramPrefix: 'DEMO_'
)

if (!projectMap.containsKey(params.TARGET_PROJECT)) {
    error('TARGET_PROJECT is not in the trusted project map')
}

校验通过后返回结构化列表,而不是继续在后续阶段散着读取 params。这样汇总输出、变量生成和下游触发看到的是同一份数据。

项目 ID 和凭据不做构建参数
#

原型里容易出现一组“方便调试”的自由文本参数:GitLab 项目 ID、分支、凭据 ID 和超时时间。长期保留这些入口,会把原本需要代码评审的配置交给构建用户。

公开 demo 改成了受信配置:

def projectMap = [
    'demo-java': '1001',
    'demo-rust': '1002'
]

def trusted = [
    gitlabUrl: 'https://gitlab.example.com',
    gitlabBranch: 'main',
    gitlabCredentialId: 'gitlab-api-token',
    timeoutMinutes: 30,
    pollIntervalSeconds: 10
]

用户看到的是项目别名。项目 ID、GitLab 地址和凭据 ID 留在 Jenkinsfile 或受版本控制的配置中。修改它们要走代码评审。

凭据使用 Jenkins Secret text,作用域只覆盖 HTTP 请求:

withCredentials([
    string(credentialsId: credentialId, variable: 'GITLAB_API_TOKEN')
]) {
    httpRequest(
        customHeaders: [[
            name: 'PRIVATE-TOKEN',
            value: env.GITLAB_API_TOKEN,
            maskValue: true
        ]],
        consoleLogResponseBody: false,
        quiet: true
    )
}

日志可以打印 Pipeline ID、状态和 Web URL,但不打印 token,也不打印完整响应正文。

只传允许的变量
#

下游变量从已经校验过的模块列表生成:

def variables = collectModuleVersions(
    selections: selections,
    variablePrefix: 'DEMO_'
)
variables['JENKINS_BUILD_NUMBER'] = env.BUILD_NUMBER
variables['JENKINS_JOB_NAME'] = env.JOB_NAME

最终契约很窄:

变量示例用途
DEMO_CORE_VERSIONv2.3.1模块版本
DEMO_CORE_VERSION_TYPErelease版本来源
JENKINS_BUILD_NUMBER42追溯 Jenkins 构建
JENKINS_JOB_NAMEsdk-demo追溯 Job

不要把整个 params Map 转发给 GitLab。Job 以后新增密码参数或临时开关时,整包转发很容易把无关数据带到下游。

下游状态要回到 Jenkins
#

触发请求成功只说明 GitLab 接受了 Pipeline。Jenkins 还要轮询状态,直到出现终态:

def terminalStatuses = [
    'success', 'failed', 'canceled', 'skipped', 'manual'
] as Set

timeout(time: timeoutMinutes, unit: 'MINUTES') {
    while (!terminalStatuses.contains(pipeline.status)) {
        sleep(time: pollIntervalSeconds, unit: 'SECONDS')
        def response = httpRequest(
            url: "${projectEndpoint}/pipelines/${pipeline.id}",
            httpMode: 'GET',
            customHeaders: [[
                name: 'PRIVATE-TOKEN',
                value: env.GITLAB_API_TOKEN,
                maskValue: true
            ]],
            validResponseCodes: '200',
            consoleLogResponseBody: false,
            quiet: true
        )
        pipeline = new JsonSlurperClassic().parseText(response.content)
        echo "GitLab pipeline ${pipeline.id}: ${pipeline.status}"
    }
}

return [
    pipelineId: pipeline.id.toString(),
    status: pipeline.status.toString(),
    webUrl: pipeline.web_url.toString()
]

manual 也按终态处理,否则 Jenkins 会一直等一个需要人工点击的 Job。共享库把终态返回给 Jenkinsfile;调用方设置纯文本描述,并且只让 success 通过:

def result = triggerGitLabPipeline(
    gitlabUrl: trusted.gitlabUrl,
    projectId: projectMap[params.TARGET_PROJECT],
    branch: trusted.gitlabBranch,
    credentialId: trusted.gitlabCredentialId,
    variables: variables,
    timeoutMinutes: trusted.timeoutMinutes
)

currentBuild.description =
    "${params.TARGET_PROJECT} | ${result.status} | pipeline ${result.pipelineId}"

if (result.status != 'success') {
    error("GitLab pipeline finished with status: ${result.status}")
}

Web URL 放到控制台。这样不需要为了可点击链接去放宽 Jenkins Markup Formatter。

先用 DRY_RUN 验参数
#

这类 Job 第一次接入时,我不会立刻连真实 GitLab。默认 DRY_RUN=true,只做三件事:

  1. 从版本 API 加载候选值;
  2. 在 Pipeline 内重新校验;
  3. 打印版本和下游变量名。

这里故意只打印变量名,不打印令牌或完整 HTTP 响应。确认参数行为稳定后,再用测试项目关闭 DRY_RUN,观察创建请求、状态轮询和失败传播。

故障怎么落下来
#

故障参数页Pipeline 结果
版本 API 超时显示 ERROR: VERSION_API_UNAVAILABLE校验失败,不绑定凭据
API 返回非 200显示 ERROR: VERSION_API_HTTP_<code>校验失败
响应结构不对显示 ERROR: VERSION_API_SCHEMA校验失败
目标项目不在映射中无有效映射校验失败
GitLab 创建请求失败参数仍有效HTTP Request 步骤失败
GitLab 返回 failedcanceled不适用Jenkins 构建失败
下游一直不结束不适用超时中止轮询

参数页故障和下游故障要分开。前者不应接触凭据,后者必须把最终状态传回 Jenkins,不能只留一个“已触发”的绿色构建。

我会这样验收
#

  1. 首次运行后,Job 页面出现完整参数;
  2. 停掉版本 API,几秒内出现 ERROR: 哨兵;
  3. 恢复 API,切换 release/commit 时版本列表随之变化;
  4. DRY_RUN=true 时,GitLab 没有新 Pipeline;
  5. 测试项目成功、失败、取消时,Jenkins 状态与之一致;
  6. 控制台搜索不到 token、凭据内容和完整响应正文。

公开 demo 的自动测试覆盖 mock API、脱敏扫描和静态契约。Active Choices 与 Jenkins Pipeline DSL 仍需要在测试 Jenkins 中做一次手工 smoke test,这部分不能靠本地 Python 测试替代。

参考实现
#

这套拆法适合模块多、版本更新频繁、下游构建需要统一追踪的 Job。只有几个固定选项时,普通 choice 更省事;版本 API 如果不能稳定响应,也不该直接挂在 Jenkins 参数页上。

相关文章

Jira Webhook Integration Jenkins
·23 字·1 分钟
CI/CD Jira 自动化 DevOps Jenkins Webhook
从一次 TDS 抓包到只读 MCP:老 ERP 接入 AI 的安全边界
·624 字·3 分钟
AI MCP ERP AI 安全 威胁建模 +3
把 Codex 会话放到远端 Mac mini:一次 Agent Deck 部署记录
·714 字·4 分钟
AI Codex Tmux SSH DevOps Agent Deck