Ansible hacking/azp 工具实战:从 Azure Pipelines 下载 CI 结果并分析 Incidental 代码覆盖率
本文以 Ansible 仓库 hacking/azp 目录下的 CI 辅助脚本为主体,讲清四个工具(run.py、download.py、get_recent_coverage_runs.py、incidental.py)各自解决什么问题,并完整走通「获取最近覆盖率运行 → 下载 CI 结果 → 分析 incidental 覆盖率报告 → 补齐测试缺口」这条工作流。读完本文,你将能够独立下载 Azure Pipelines 的覆盖率数据,定位那些"顺带覆盖"了核心代码的测试,并理解覆盖率报告中山丘弧线(arc)标记的含义。
目录概览:四个脚本各管一件事
hacking/azp/README.md 声明该目录包含以下脚本,每个脚本的职责与源码中的实现一一对应:
| 脚本 | 职责 | 关键实现入口 |
|---|---|---|
download.py |
从 CI 下载运行结果 | 解析 run id,下载 artifacts/日志/元数据 |
get_recent_coverage_runs.py |
获取最近覆盖率测试运行的 CI URL 与状态 | 轮询 pipeline 20 的运行列表 |
incidental.py |
基于 CI 数据生成 incidental 覆盖率报告 | 组合 ansible-test coverage analyze targets 子命令 |
run.py |
在 CI 上发起新的运行 | 调用 Azure Pipelines REST API 创建 run |
run.py:手动触发一次 CI 运行
run.py 通过 Azure Pipelines REST API 发起新运行,源码中的参数定义(hacking/azp/run.py#L58-L77)如下:
-p, --pipeline-id:要触发的 pipeline,默认值为20(Ansible 官方 pipeline);--ref:要运行的 git 引用(分支/标签);--env KEY VALUE:传递给运行的环境变量,可重复出现(nargs=2, action='append')。
使用前必须设置 AZP_TOKEN 环境变量,否则脚本直接报错退出(见 hacking/azp/run.py#L50-L53):
export AZP_TOKEN="<your token>"
hacking/azp/run.py --ref devel --env KEY VALUE
start_run 函数会向 https://dev.azure.com/ansible/ansible/_apis/pipelines/<id>/runs 发送 POST 请求,并把返回的运行信息以 JSON 打印到终端。源码注释中还留有一条 TODO,提示 Dev 团队缺少 AZP token、该路径未被充分测试,使用时如遇到认证问题属于已知情况。
第一步:获取最近的覆盖率运行
覆盖率工作流的第一步是找到最近一次带代码覆盖率的 CI 运行。仓库每天自动在 Azure Pipelines 上执行一次全量测试并开启代码覆盖率,get_recent_coverage_runs.py 负责列出这些运行:
hacking/azp/get_recent_coverage_runs.py <optional branch name>
分支名默认为 devel(对应源码中的 BRANCH = 'devel',若 sys.argv 有值则覆盖,见 hacking/azp/get_recent_coverage_runs.py#L33-L38)。
从源码可以确认它的筛选逻辑(hacking/azp/get_recent_coverage_runs.py#L41-L77):
- 拉取 pipeline 20 最近至多 1000 条运行;
- 过滤出指定分支(
refs/heads/<branch>)的运行; - 过滤掉超过 24 小时(
MAX_AGE = datetime.timedelta(hours=24))的运行; - 只保留 artifact 名称以
Coverage开头的运行——即真正开启了覆盖率采集的运行。
输出格式为每行一条,带彩色 PASS/FAIL 标记与 Azure Pipelines 构建 URL,进行中的运行单独列在末尾。源码中还有一段兼容性处理:遇到使用容器资源的老运行会因 Azure API 序列化错误返回 500,脚本会直接 break 停止继续翻页,避免无谓请求失败。
拿到 run id(URL 中的 buildId)后,即可进入下一步下载结果。
第二步:下载 CI 结果到本地
hacking/azp/download.py <run id> --artifacts --run-metadata -v 是 README 给出的标准用法。其完整参数(解析逻辑见 hacking/azp/download.py#L55-L119):
| 参数 | 说明 |
|---|---|
RUN(位置参数) |
AZP 运行 id,或直接的构建 URL,如 https://dev.azure.com/ansible/ansible/_build/results?buildId=14075。run_id_arg 用正则提取其中的纯数字 id |
-v, --verbose |
打印实际下载了什么 |
-t, --test |
dry-run,只显示将下载的内容而不真正下载 |
-p, --pipeline-id |
pipeline id,默认 20,仅影响 run 元数据请求的 URL |
--artifacts |
下载 artifacts(zip 包,解压到运行 id 命名的目录) |
--console-logs |
下载各 job 的控制台日志 |
--run-metadata |
下载运行元数据并保存为 run.json |
--all |
等价于同时打开上面三项 |
--match-artifact-name |
只下载文件名匹配该正则的 artifact |
--match-job-name |
只处理 job 名(<父job名> <子job名>)匹配该正则的内容 |
结果统一落到以 run id 命名的目录(output_dir = '<run id>'),例如:
# 结果下载到当前目录下的 ansible/ansible 路径,14075 替换为你要下载的运行号
hacking/azp/download.py 14075 --artifacts --run-metadata -v
从源码看,下载流程为:
--run-metadata时请求 pipeline run API,把完整 JSON 写入<run id>/run.json;- 请求构建的 timeline 接口,把 job/step 构建出父子关系树,再按
--match-job-name决定哪些子树"允许下载"; --artifacts时遍历 artifact 列表,逐个下载 zip 并在内存中用zipfile解压到输出目录;--console-logs时沿 timeline 的父子链拼接出父job 子job step风格的日志文件名(把路径分隔符替换为_)逐个保存。
注意两点前提:run.json 必须存在,后续 incidental.py 依赖它读取被测 commit sha 与运行结果(见下文);至少选择 --artifacts/--run-metadata/--console-logs 之一,否则脚本以 parser.error 报错退出。
什么是 Incidental Code Coverage
incidental.py 是整套工具中技术含量最高的部分,理解它的前提是先理解 README 中定义的 incidental 概念:
当一个测试在测试 A 代码的同时,非预期地顺带覆盖了一部分 B 代码,就产生了 incidental 测试与代码覆盖率。
原文档给的例子:dnf 集成测试本意是测 dnf 模块,但过程中同时使用并"无意"测试了 file 模块。
这个概念与 Ansible 模块化历史强相关。README 说明:在把模块和插件迁移进 collections 的过程中,发现了一些独占性 incidental 覆盖——即即将随迁移出仓库的测试,所覆盖的代码在迁移后没有任何剩余测试再覆盖。为避免覆盖丢失,这些集成测试目标被加上 incidental_ 前缀保留在仓库中,其依赖的插件也被保留在 test/support 目录下。当前仓库中可以看到这样的存量目标,例如 test/integration/targets/incidental_win_reboot。这些 incidental 测试的长期目标是被有意的(intentional)测试替代:随着有意测试的增加,incidental 测试提供的独占覆盖会下降,降到零后即可删除,而不损失任何测试覆盖。
减少 Incidental 覆盖的完整工作流
README 给出了四步流程,下面逐步展开并补充源码依据。
步骤 1:获取最近的覆盖率运行 URL
即上文 get_recent_coverage_runs.py 的用法。
步骤 2:下载覆盖率数据
即上文 download.py 的用法。
步骤 3:分析每个测试覆盖的代码
# 确认 ansible-test 在 $PATH 中
source hacking/env-setup
# 用实际下载结果的目录名替换 14075/
hacking/azp/incidental.py 14075/
incidental.py 的参数全集(hacking/azp/incidental.py#L55-L107):
| 参数 | 说明 |
|---|---|
result(位置参数) |
从 Azure Pipelines 下载的结果目录(必须是目录,否则报错) |
--output |
报告输出目录,默认 test/results/.tmp/incidental |
--source |
Ansible 源码 git 仓库路径,默认取脚本所在仓库根 |
--skip-checks |
跳过一致性检查,仅供调试 |
--ignore-cache |
忽略已缓存的中间文件 |
-v, --verbose |
提高输出详细度 |
--result-sha |
覆盖从 run.json 中读取的结果 sha |
--targets |
待分析 target 的正则,默认 ^incidental_ |
--plugin-path |
改为对指定插件路径报告"其自身测试缺失的" incidental 覆盖;与 --targets 互斥 |
从源码看其内部执行链路(incidental_report 函数,hacking/azp/incidental.py#L131-L257):
- 读取运行元数据:
CoverageData从结果目录中的run.json取出被测 commit(resources.repositories.self.version)与运行结果result,并从 glob 到的各 job 产物*/coverage-analyze-targets.json收集覆盖率数据。这正是download.py --run-metadata --artifacts必须同时使用的底层原因; - 一致性检查:被测 commit 必须在本仓库可
git show(否则提示"先更新你的源码仓库");若运行结果不是succeeded则拒绝继续(可用--skip-checks降级为警告);若无coverage-analyze-targets.json则报错提示"确认下载的是覆盖率运行的结果"; - 生成哈希子目录:对所有输入覆盖率文件路径做 SHA-256,得到
test/results/.tmp/incidental/{hash}/,这与 README 中"{hash}基于生成报告所用输入文件"的描述一致; - 调用 ansible-test 子命令做集合运算:
CoverageTool类封装了对ansible-test coverage analyze targets的调用,依次执行combine(合并各 job 报告)、filter(按 target 保留/排除,得到only-<target>.json与without-<target>.json)、missing(求差集,--only-gaps只保留缺口)、expand(展开行号区间)。这套子命令的实现在 test/lib/ansible_test/_internal/commands/coverage/analyze/targets/ 下,包含combine.py、filter.py、missing.py、expand.py、generate.py等模块; - 求独占覆盖:默认模式下,
exclusive = missing(only_target, without_target),即"只有该 target 覆盖、其他所有 target 都不覆盖"的代码行/弧; - 生成文本报告:对每个 target 写出
reports/<target>.txt,并在终端打印汇总行<target>: N arcs, M lines, K files - <report path>。所有中间产物通过cached()辅助函数做文件级缓存,重复运行可跳过已生成的文件。
另外注意源码中两处硬编码的排除项:test/support/ 下的测试支持插件与 lib/ansible/module_utils/six/ 不参与分析(后者被注释说明"会报告虚假的注释行覆盖")。
步骤 4:编写有意测试补齐缺口
根据 test/results/.tmp/incidental/{hash}/reports/ 下的报告,为新覆盖的代码创建新测试或扩展现有测试。随着该过程循环进行,独占覆盖会逐步下降;当某个 incidental 测试不再提供独占覆盖时即可删除。README 特别警告:一次只能删一个 incidental 测试,因为删掉一个后,原本由它分担覆盖的代码可能使另一个测试获得新的独占覆盖。
针对插件的覆盖率缺口分析
incidental 分析不限于 incidental_ 前缀的测试:某个 filter 插件自身测试覆盖不全时,缺口可能由无关测试顺带填补,incidental.py 同样能定位这些缺口。用法是在步骤 3 中加 --plugin-path {path_to_plugin},可对任意多个插件重复执行。
一次分析所有 filter 插件的示例(README 原文):
find lib/ansible/plugins/filter -name '*.py' -not -name __init__.py -exec hacking/azp/incidental.py 14075/ --plugin-path '{}' ';'
指定 --plugin-path 后,脚本行为切换为"missing"模式(missing = True):把插件路径映射到其集成测试 target 名(get_target_name_from_plugin_path,如 lib/ansible/modules/dnf.py → dnf,lib/ansible/plugins/filter/xxx.py → filter_xxx,见 hacking/azp/incidental.py#L259-L280),然后计算 missing(without_target, only_target),即"该插件自身测试未覆盖、但其他测试覆盖了"的缺口。即使该插件没有对应测试 target,也会生成一份报告说明缺失的覆盖。README 同时提醒:报告不标注这些 incidental 覆盖来自哪个测试。
如何阅读覆盖率报告
每行被覆盖的代码都会出现在报告中:左列是源码行号;若是 Python 代码,行尾注释还会标注涉及的覆盖弧(arc)。README 给出的真实报告样例:
Target: incidental_win_psexec
GitHub: https://github.com/ansible/ansible/blob/6994ef0b554a816f02e0771cb14341a421f7cead/test/integration/targets/incidental_win_psexec
Source: lib/ansible/executor/task_executor.py (2 arcs, 3/1141 lines):
GitHub: https://github.com/ansible/ansible/blob/6994ef0b554a816f02e0771cb14341a421f7cead/lib/ansible/executor/task_executor.py
705 if 'rc' in result and result['rc'] not in [0, "0"]: ### (here) -> 706
706 result['failed'] = True ### 705 -> (here) ### (here) -> 711
711 if self._task.until: ### 706 -> (here)
报告头部给出产生该覆盖的 target 名,以及指向目标目录与源码文件的链接——链接中的 commit 与覆盖率数据匹配,确保看到的代码与 CI 实际测试的代码一致。
弧标记的语义(README 原文解释,与 hacking/azp/incidental.py#L409-L428 的报告生成逻辑一致):
### (here) -> 706(写在第 705 行)表示执行流从第 705 行进到第 706 行,可以有多个出边行号;### 706 -> (here)(写在第 711 行)表示执行流从第 706 行进到第 711 行,可以有多个入边行号;(here)只是"当前这一行"的占位引用。
弧(arc)信息仅对 Python 代码可用;PowerShell 代码只报告被覆盖的行号。终端汇总行中也会区分统计口径:exclusive 模式显示 N arcs, M lines,纯行号模式(如 PowerShell)只显示 M lines(报告头部相应地省略 arcs 计数,见 hacking/azp/incidental.py#L391-L405)。
适用前提小结
- 四个脚本全部面向 Ansible 官方 Azure Pipelines(pipeline 20、
dev.azure.com/ansible/ansible项目),run.py还需要有效的AZP_TOKEN; incidental.py依赖ansible-test在$PATH中(source hacking/env-setup)、依赖一个完整的源码 git 仓库(需要能git show被测 commit)、以及包含run.json与各 jobcoverage-analyze-targets.json的下载结果目录;- 报告中的 commit 链接以 CI 实际测试的 commit 为准,与本地未推送的代码可能不一致,因此分析前应先同步源码;
- 所有中间结果默认缓存在
test/results/.tmp/incidental/{hash}/下,可用--ignore-cache强制重新生成。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00