首页
/ Ansible hacking/azp 工具实战:从 Azure Pipelines 下载 CI 结果并分析 Incidental 代码覆盖率

Ansible hacking/azp 工具实战:从 Azure Pipelines 下载 CI 结果并分析 Incidental 代码覆盖率

2026-09-04 17:05:35作者:贡沫苏Truman

本文以 Ansible 仓库 hacking/azp 目录下的 CI 辅助脚本为主体,讲清四个工具(run.pydownload.pyget_recent_coverage_runs.pyincidental.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):

  1. 拉取 pipeline 20 最近至多 1000 条运行;
  2. 过滤出指定分支(refs/heads/<branch>)的运行;
  3. 过滤掉超过 24 小时(MAX_AGE = datetime.timedelta(hours=24))的运行;
  4. 只保留 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=14075run_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

从源码看,下载流程为:

  1. --run-metadata 时请求 pipeline run API,把完整 JSON 写入 <run id>/run.json
  2. 请求构建的 timeline 接口,把 job/step 构建出父子关系树,再按 --match-job-name 决定哪些子树"允许下载";
  3. --artifacts 时遍历 artifact 列表,逐个下载 zip 并在内存中用 zipfile 解压到输出目录;
  4. --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):

  1. 读取运行元数据CoverageData 从结果目录中的 run.json 取出被测 commit(resources.repositories.self.version)与运行结果 result,并从 glob 到的各 job 产物 */coverage-analyze-targets.json 收集覆盖率数据。这正是 download.py --run-metadata --artifacts 必须同时使用的底层原因;
  2. 一致性检查:被测 commit 必须在本仓库可 git show(否则提示"先更新你的源码仓库");若运行结果不是 succeeded 则拒绝继续(可用 --skip-checks 降级为警告);若无 coverage-analyze-targets.json 则报错提示"确认下载的是覆盖率运行的结果";
  3. 生成哈希子目录:对所有输入覆盖率文件路径做 SHA-256,得到 test/results/.tmp/incidental/{hash}/,这与 README 中"{hash} 基于生成报告所用输入文件"的描述一致;
  4. 调用 ansible-test 子命令做集合运算CoverageTool 类封装了对 ansible-test coverage analyze targets 的调用,依次执行 combine(合并各 job 报告)、filter(按 target 保留/排除,得到 only-<target>.jsonwithout-<target>.json)、missing(求差集,--only-gaps 只保留缺口)、expand(展开行号区间)。这套子命令的实现在 test/lib/ansible_test/_internal/commands/coverage/analyze/targets/ 下,包含 combine.pyfilter.pymissing.pyexpand.pygenerate.py 等模块;
  5. 求独占覆盖:默认模式下,exclusive = missing(only_target, without_target),即"只有该 target 覆盖、其他所有 target 都不覆盖"的代码行/弧;
  6. 生成文本报告:对每个 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.pydnflib/ansible/plugins/filter/xxx.pyfilter_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 与各 job coverage-analyze-targets.json 的下载结果目录;
  • 报告中的 commit 链接以 CI 实际测试的 commit 为准,与本地未推送的代码可能不一致,因此分析前应先同步源码;
  • 所有中间结果默认缓存在 test/results/.tmp/incidental/{hash}/ 下,可用 --ignore-cache 强制重新生成。
登录后查看全文
热门项目推荐
相关项目推荐