本文根据一份实际使用的统一构建 Jenkinsfile 整理,复核基线为 commit
b133caae。内部域名、项目 ID、凭据 ID、模块名和制品地址均已泛化。公开示例是重新实现,不复制内部共享库源码。
完整 demo 放在 cdryzun/jenkins-dynamic-parameters-demo。仓库默认 DRY_RUN=true,自带本地版本 API、共享库步骤、测试和 GitHub Actions。
参数之间有依赖#
这份任务要收集三类输入:
| 输入 | 来源 | 约束 |
|---|---|---|
| 目标项目 | Jenkinsfile 中的项目映射 | 用户只能选别名,不能直接填项目 ID |
| 模块版本类型 | Active Choices 单选项 | 只接受 release 或 commit |
| 模块版本 | 版本 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-bar 和 foo_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')
}
实际操作顺序是:
- 先运行一次,让 Jenkins 写入参数定义;
- 回到 Job 页面,打开 Build with Parameters;
- 选择项目和各模块版本,再开始正式构建。
这不是 Active Choices 的网络问题。排查参数页时,先确认 Job 是否完成过第一次配置刷新,可以省掉不少无效调试。
通过 REST API 触发 Jenkins 时,调用方也不会自动获得参数页的级联交互。它必须传齐最终参数,Pipeline 的二次校验负责兜底。
Pipeline 仍要重新校验#
参数页的值不能直接传给 GitLab。用户可以通过 Jenkins API 发起构建,版本 API 的数据也可能在页面打开后变化。Pipeline 至少要检查:
- 版本类型是否为
release或commit; - 版本是否为空或以
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_VERSION | v2.3.1 | 模块版本 |
DEMO_CORE_VERSION_TYPE | release | 版本来源 |
JENKINS_BUILD_NUMBER | 42 | 追溯 Jenkins 构建 |
JENKINS_JOB_NAME | sdk-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,只做三件事:
- 从版本 API 加载候选值;
- 在 Pipeline 内重新校验;
- 打印版本和下游变量名。
这里故意只打印变量名,不打印令牌或完整 HTTP 响应。确认参数行为稳定后,再用测试项目关闭 DRY_RUN,观察创建请求、状态轮询和失败传播。
故障怎么落下来#
| 故障 | 参数页 | Pipeline 结果 |
|---|---|---|
| 版本 API 超时 | 显示 ERROR: VERSION_API_UNAVAILABLE | 校验失败,不绑定凭据 |
| API 返回非 200 | 显示 ERROR: VERSION_API_HTTP_<code> | 校验失败 |
| 响应结构不对 | 显示 ERROR: VERSION_API_SCHEMA | 校验失败 |
| 目标项目不在映射中 | 无有效映射 | 校验失败 |
| GitLab 创建请求失败 | 参数仍有效 | HTTP Request 步骤失败 |
GitLab 返回 failed 或 canceled | 不适用 | Jenkins 构建失败 |
| 下游一直不结束 | 不适用 | 超时中止轮询 |
参数页故障和下游故障要分开。前者不应接触凭据,后者必须把最终状态传回 Jenkins,不能只留一个“已触发”的绿色构建。
我会这样验收#
- 首次运行后,Job 页面出现完整参数;
- 停掉版本 API,几秒内出现
ERROR:哨兵; - 恢复 API,切换
release/commit时版本列表随之变化; DRY_RUN=true时,GitLab 没有新 Pipeline;- 测试项目成功、失败、取消时,Jenkins 状态与之一致;
- 控制台搜索不到 token、凭据内容和完整响应正文。
公开 demo 的自动测试覆盖 mock API、脱敏扫描和静态契约。Active Choices 与 Jenkins Pipeline DSL 仍需要在测试 Jenkins 中做一次手工 smoke test,这部分不能靠本地 Python 测试替代。
参考实现#
这套拆法适合模块多、版本更新频繁、下游构建需要统一追踪的 Job。只有几个固定选项时,普通 choice 更省事;版本 API 如果不能稳定响应,也不该直接挂在 Jenkins 参数页上。
