Ansible CI 失败日志排障实战:/azp-logs 技能与 hacking/azp/download.py 深度解析
本文以仓库中的 azp-logs 技能定义 为核心,系统讲解 Ansible 项目中如何把 Azure Pipelines(AZP)CI 构建日志下载到本地进行故障分析:从 /azp-logs 的三种入参形式、完整执行流程,到底层脚本 download.py 的 timeline API 调用、job 过滤与日志命名规则。读完本文,你既能熟练用该技能拉取 CI 日志定位测试失败,也能从源码层面理解"一条构建 ID 如何变成一组 .log 文件"的完整链路。
技能定位:ansibot 与 Web UI 之外的最后一道排障手段
Ansible 的 CI 跑在 Azure Pipelines 上,PR 提交后由 CI 机器人(ansibot)在 PR 评论区回报失败摘要。AGENTS.md 给出的标准排障路径是三级递进:
- 先读 ansibot 评论(
gh pr view <number> --comments),多数 sanity 失败会直接给出文件与行号; - 用
gh pr checks <number>拿到 Azure Pipelines 构建 URL 与逐 job 状态; - 当上述信息不足以定位问题时,用
/azp-logs技能把完整控制台日志拉到本地做细粒度分析。
azp-logs 技能 就定义在这条链路的最末端。它本质是一个 Claude Code 技能,YAML frontmatter 声明了它的身份与约束:
name: azp-logs
description: Download Azure Pipelines CI logs for analysis
argument-hint: <pr_number|build_id|build_url>
allowed-tools: [Bash(gh pr view:*), Bash(gh pr checks:*), Bash(ls:*), Read, Grep]
user-invocable: true
几个关键字段的含义:argument-hint 提示该技能接受三种入参之一;allowed-tools 限定技能执行时只允许使用 gh pr view、gh pr checks、ls 以及文件读取/检索类工具——即它自身不直接执行下载命令,下载动作由 hacking/azp/download.py 这个长期存在于仓库中的脚本承担,技能只是把它包装成一条带交互确认的"下载—定位失败"工作流;user-invocable: true 表示用户可以直接以 /azp-logs 前缀调用。
调用方式与三种入参
技能文档给出的调用格式为:
/azp-logs <pr_number|build_id|build_url>
三种入参的语义与处理路径如下(均出自 SKILL.md):
| 入参 | 说明 | 技能内部的处理方式 |
|---|---|---|
pr_number |
GitHub PR 编号 | 先执行 gh pr checks <number>,从最近一次 CI 运行的检查项中提取 Azure Pipelines URL,进而得到 build ID |
build_id |
Azure Pipelines 构建 ID(纯数字) | 直接传给 download.py |
| "build_url" | 完整构建结果 URL,形如 https://dev.azure.com/ansible/ansible/_build/results?buildId=12345 |
直接传给 download.py,脚本内部自动解析出 buildId |
关于 URL 形式的入参,可以在脚本源码中得到精确印证。download.py 的 run_id_arg 函数 用一个正则同时接受"纯数字 ID"和"dev.azure.com 构建结果 URL"两种写法:
def run_id_arg(arg):
m = re.fullmatch(r"(?:https:\/\/dev\.azure\.com\/ansible\/ansible\/_build\/results\?buildId=)?(\d+)", arg)
if not m:
raise ValueError("run does not seems to be a URI or an ID")
return m.group(1)
也就是说 URL 前缀是可选的,最终归一化输出的一定是纯数字 ID——后续所有 API 请求、输出目录命名都基于这个数字 ID。
下载前的预期管理
技能文档把"下载前的告知义务"单独列为强约束:执行前必须先征得用户确认,并说明三点预期(引自 SKILL.md):
- 一次完整 CI 运行的日志下载通常耗时 5–10 分钟,非常大的 CI 运行可能更久;
- 日志会保存到以构建 ID 命名的目录
<build_id>/下; - 下载体积视 job 数量而定,常见约 10–50MB。
这些提示解释了为什么 gh pr checks 给出的 URL 虽然"随手可见",却仍值得一条专用技能来封装耗时操作。
技能执行流程四步走
SKILL.md 定义的标准执行序列是:
- 向用户确认:说明将要下载什么、预计耗时;
- 确定 build ID:按上表三种入参路径之一解析;
- 下载日志:执行
这是技能推荐的默认命令——只取控制台日志并开启./hacking/azp/download.py <build_id_or_url> --console-logs -v-v显示正在下载哪些文件,适合绝大多数 CI 失败分析场景; - 分析日志:进入
<build_id>/目录,grep 常见失败特征(见后文"日志分析实操"一节),聚焦失败 job 的日志,并与 ansibot 评论相互印证。
第 3 步命令中"控制台日志"为何是首选,第 4 步"job 名怎么对应到文件",都要从脚本的下载实现讲起。
download.py 选项全解:以源码为准的完整参数表
SKILL.md 列出的选项与 download.py 的参数解析代码 完全对应,此处以源码为准补全每个选项的精确语义:
| 选项 | 源码位置 | 作用 |
|---|---|---|
RUN(位置参数) |
L60 | 构建 ID 或 dev.azure.com 构建 URL,经 run_id_arg 归一化为数字 |
-v, --verbose |
L62-L65 | 打印正在下载的每个文件路径 |
-t, --test |
L67-L70 | 干跑模式:只列出"将会下载"的内容,不实际下载、不创建目录 |
-p, --pipeline-id |
L72 | 指定 pipeline 编号,默认 20,仅影响 run 元数据请求 |
--artifacts |
L74-L76 | 下载测试制品(zip 包)并解压 |
--console-logs |
L78-L80 | 下载控制台日志(CI 失败分析推荐) |
--run-metadata |
L82-L84 | 下载运行元数据 JSON |
--all |
L86-L88 | 等价于同时开启上三项 |
--match-artifact-name |
L90-L93 | 正则过滤制品/日志名称,默认 .*(不过滤) |
--match-job-name |
L95-L98 | 正则过滤 job,默认 .*(不过滤) |
源码中有两处逻辑值得特别注意:
其一,--all 并非独立功能,而是在解析后展开为三个开关(L105-L108):
if args.all:
args.artifacts = True
args.run_metadata = True
args.console_logs = True
其二,脚本强制要求至少选择一种下载类型,否则直接报错退出(L110-L117):
selections = (
args.artifacts,
args.run_metadata,
args.console_logs
)
if not any(selections):
parser.error('At least one download option is required.')
这意味着 ./hacking/azp/download.py 12345 -v 这样的"裸"命令会被拒绝,必须显式声明下载意图。此外脚本头部带有 # PYTHON_ARGCOMPLETE_OK 标记并可选加载 argcomplete(L2-L2、L100-L101),在安装了 argcomplete 的环境中可获得 shell 补全。
源码深潜:一条构建 ID 如何变成一组 .log 文件
download_run 函数(L122-L221)是整个脚本的核心,它对 Azure DevOps REST API 发起了最多四类请求,全部通过 urllib.request 匿名访问——这也印证了技能文档中"Ansible 项目是公开的,下载无需认证"的说法。
1. 输出目录以构建 ID 命名
output_dir = '%s' % args.run
非干跑模式下先创建 <build_id>/ 目录,后续所有元数据、制品、日志都落在其中。这就是技能文档"日志保存到以构建 ID 命名的目录"的实现来源。
2. 运行元数据:写入 run.json
开启 --run-metadata 时,请求 pipeline runs 接口(L131-L143):
run_url = 'https://dev.azure.com/ansible/ansible/_apis/pipelines/%s/runs/%s?api-version=6.0-preview.1' % (args.pipeline_id, args.run)
...
path = os.path.join(output_dir, 'run.json')
contents = json.dumps(run, sort_keys=True, indent=4)
响应 JSON 以键排序、4 空格缩进后写入 run.json。注意 URL 中的 pipeline ID 来自 -p 参数(默认 20)。
3. timeline:构建父子关系树,这是日志命名与 job 过滤的基础
无论选择哪种下载类型,脚本都会请求构建 timeline(L145-L161):
with urllib.request.urlopen('https://dev.azure.com/ansible/ansible/_apis/build/builds/%s/timeline?api-version=6.0' % args.run) as timeline_response:
timeline = json.load(timeline_response)
然后遍历 timeline['records'],以每条记录的 id/parentId 构建出 roots(无父节点者)、by_id、parent_of、children_of 四张索引,即整棵 stage → job → task 的层级树。
job 过滤发生在树的"第一层"(L163-L177):对每个根节点(stage),检查其每个直接子节点(job),当且仅当 --match-job-name 正则能匹配上 "stage 名 job 名" 的拼接串时才放行:
for ci in children_of.get(r['id'], []):
c = by_id[ci]
if not args.match_job_name.match("%s %s" % (r['name'], c['name'])):
continue
allow_recursive(c['id'])
allow_recursive 会把被放行 job 的整个子树(含全部子任务)加入允许集 allowed。这里可以推断两点:正则匹配的对象是"stage 名 + 空格 + job 名",所以像 AGENTS.md 中高级用法示例的 --match-job-name "Sanity.*",实际是去匹配包含 sanity 测试 job 的那一层;而一旦某个 job 被放行,其内部所有 task 日志都会随之下载。
4. 控制台日志:按层级命名落盘
开启 --console-logs 时(L197-L221),脚本遍历所有 timeline 记录,对"带有 log 下载链接、id 在允许集内、且名称通过 --match-artifact-name 过滤"的记录,沿着 parent_of 链一路回溯到根,把各级名称收集起来:
names = []
parent_id = r['id']
while parent_id is not None:
p = by_id[parent_id]
name = p['name']
if name not in names:
names = [name] + names
parent_id = parent_of.get(p['id'], None)
path = " ".join(names)
# Some job names have the separator in them.
path = path.replace(os.sep, '_')
log_path = os.path.join(output_dir, '%s.log' % path)
由此得到技能文档那条命名规则的确切含义:控制台日志文件以"Stage 名 Job 名(.log)"命名,即从该记录沿层级树回溯得到的名称序列按空格拼接;若名称中恰好含路径分隔符则替换为 _ 避免误入子目录。日志内容则是直接流式复制 record['log']['url'] 指向的远端文件。
5. 制品:zip 流式下载并就地解压
开启 --artifacts 时,脚本先拉取制品列表接口,再逐个下载(L179-L195)。每个制品须同时满足"其来源 job 在允许集内"与"名称匹配 --match-artifact-name"两个条件,下载后按 zip 包解压到 <build_id>/:
with urllib.request.urlopen(artifact['resource']['downloadUrl']) as response:
with io.BytesIO() as buffer:
shutil.copyfileobj(response, buffer)
buffer.seek(0)
with zipfile.ZipFile(buffer) as archive:
archive.extractall(path=output_dir)
整条链路自始至终没有认证参数,依赖项目本身的公开访问权限。
日志分析实操:grep 模式与失败类型对照
日志落地后,SKILL.md 给出的分析模式如下,可直接复用:
# 找出所有错误与失败
grep -r "FAILED\|ERROR\|Traceback" <build_id>/
# 定位具体失败的测试
grep -r "FAILED test" <build_id>/
# 定位 sanity 测试失败
grep -r "The test" <build_id>/ | grep -i "failed"
# 列出所有已下载的日志文件
ls -lh <build_id>/
配合 AGENTS.md 中"Advanced usage"一节的两个实战变体,可以进一步收窄下载范围、把 5–10 分钟的全量下载压缩到只拉需要的 job:
# 只下载 sanity 相关 job 的日志
./hacking/azp/download.py <build_id> --console-logs --match-job-name "Sanity.*"
# 下载全部(日志 + 制品 + 元数据)
./hacking/azp/download.py <build_id> --all
grep 结果如何解读,可以对照仓库中 CI 常见失败模式文档 的三分法:
- Sanity 失败:通常有明确修法(尾随空白、import 错误等),日志中一般会带
file:line引用,是最好处理的一类; - 集成测试失败:可能需要特定平台的容器或测试调整,需要完整阅读失败 job 的输出与 traceback;
- 单元测试失败:往往指向真实代码问题,需要在本地用
ansible-test复现调试。
分析时的完整闭环是:先在 gh pr checks 输出里圈出标记为失败的 job 名 → 在 <build_id>/ 中按 "Stage 名 Job 名.log" 规则找到对应日志 → grep 定位错误行 → 与 ansibot 评论中的摘要比对,确认上下文一致。
同一脚本的另一面:coverage 制品下载
download.py 虽然由 azp-logs 技能在"排障"语境下主推,但它本身是通用 CI 结果下载器。hacking/azp/README.md 记录了它在"附带性代码覆盖率(incidental coverage)"分析流程中的用法:
# 下载指定构建的制品与元数据到 ansible/ansible 目录
hacking/azp/download.py 14075 --artifacts --run-metadata -v
# 随后结合 incidental.py 分析覆盖率
hacking/azp/incidental.py 14075/
可见 --artifacts --run-metadata 组合(不取控制台日志)对应的是"分析制品数据"场景,而 --console-logs 对应"排障"场景——这正是技能文档推荐"多数 CI 失败分析用 --console-logs"的原因。
使用注意事项小结
- 无需认证:脚本对 dev.azure.com 的接口均为匿名访问,前提是 Ansible 项目保持公开;
- 目录约定:一切下载产物集中在以构建 ID 命名的目录中,
ls -lh <build_id>/是最快的全局盘点方式; - 干跑先行:面对超大 CI 运行,可先加
-t观察将要下载的规模,再决定是否全量执行; - 过滤优先:
--match-job-name/--match-artifact-name均为完整正则(默认.*全取),在只需个别 job 日志时应显式收窄,既省时间也减小目录规模; - 入参归一化:无论传 PR 派生的 URL、完整构建 URL 还是纯数字 ID,最终都以数字构建 ID 作为 API 路径参数与输出目录名;
- 参数互斥性:至少必须指定
--console-logs/--artifacts/--run-metadata/--all之一,否则脚本直接报错。
从 .claude/skills/azp-logs/SKILL.md 的技能定义,到 hacking/azp/download.py 的 timeline 树构建与日志落盘逻辑,再到 AGENTS.md 的三级排障工作流,Ansible 项目把"CI 失败深挖"沉淀成了一条工具链完整、可被 Agent 直接调用的标准化路径:先靠 ansibot 评论快速定性,必要时一条命令把远端日志搬回本地,用 grep 完成从"红叉"到 file:line 的最后一步定位。
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 StartedRust0622
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