TT Lab
开始
学习 学习路径 课程

Ansible 基础

非得用 shell 的话,你要自己扛下什么

在 TT Lab 中继续学习

目标

把同样的命令分别用 command 和 shell 抛出去,用数字测量有什么分歧,并亲手练习在必须使用 shell 时,自己建立报告标准、失败标准和管道退出码。最后要创建一个审计工具,找出没有 guard 就交给 shell 的 task。

为什么重要

第一次使用 Ansible,playbook 很容易变成通过 SSH 执行的 shell 脚本。它确实能跑,但对“现在已经是那个状态了吗”“这次改变了什么”“是失败还是成功”这些问题,什么都回答不了。模块就是为回答这三个问题而创造的,所以如果有做同样事情的模块,就应该优先用它。话虽如此,也不可能永远不用 shell——总会剩下没有模块可用的事。重要的是,要知道一旦交给 shell,Ansible 原本替你做的判断就全部消失了,并把这些判断亲手重新写进去。本实验会把这三项判断逐一建立起来。

步骤

  1. 创建 /root/ansmod/hosts.ini——在 [web] 组中放入 web1 和 web2,两者都带有 ansible_host=127.0.0.1 和 ansible_port=2222,并通过 [all:vars] 设置 ansible_user=root。然后把 ansible-doc -s ansible.builtin.command 的输出保存到 /root/ansmod/out/doc-command.txt,把 ansible-doc -s ansible.builtin.shell 的输出保存到 /root/ansmod/out/doc-shell.txt。
  2. 在 /root/ansmod/files/ 中创建三个空文件 a.txt、b.txt、c.txt。创建 /root/ansmod/boundary.yml,在 web1 上运行四个 task——用 command 运行一次 ls /root/ansmod/files/*.txt(即使失败也继续),用 shell 运行一次 ls /root/ansmod/files/*.txt | wc -l,用 command 运行一次 echo one two three | wc -w,再用 shell 运行一次同样的命令。把四个结果恰好写成四行,保存到 /root/ansmod/out/boundary.txt:glob command rc=<값> / glob shell stdout=<값> / pipe command stdout=<값> / pipe shell stdout=<값>(占位符均为值)。
  3. 创建 /root/ansmod/report.yml。在 web1 上用 ansible.builtin.command 运行 id -un,以 who 进行 register,并从其返回值中只取出四个字段,以 JSON 保存到 /root/ansmod/out/result.json——rc、stdout、changed 直接使用返回值,cmd 是把返回值的参数列表用空格连接起来的字符串。这一步不加 changed_when。
  4. 在 /root/ansmod/report.yml 中再加入两个 task。一个用 command 运行 cat /etc/hostname,以 hn 进行 register,并加上 changed_when: false。另一个用 shell 运行 grep -c "^nosuchuser:" /etc/passwd,以 hits 进行 register,并在加上 changed_when: false 的同时,加上只有在退出码既不是 0 也不是 1 时才算失败的 failed_when。然后在 /root/ansmod/out/result.json 中再添加三个字段——hostname_changed(hn 的 changed)、grep_rc(hits 的 rc)、grep_failed(hits 的 failed)。playbook 必须一直运行到结束。
  5. 创建 /root/ansmod/pipe.yml。把同一条管道 cat /root/ansmod/missing.txt | wc -l 运行两次——一次直接用 shell(以 bare 进行 register),一次在前面加上 set -o pipefail 并把 executable 指定为 /bin/bash(以 guarded 进行 register)。两者都加上 ignore_errors: true 和 changed_when: false。把结果以四个字段保存到 /root/ansmod/out/pipe.json——bare_rc、bare_failed、guarded_rc、guarded_failed。不要创建 missing.txt。
  6. 创建 /root/ansmod/modernize.yml。一次都不使用 command 和 shell,完成以下事项——以权限 0750 创建 /root/ansmod/app 目录,以权限 0640 在 /root/ansmod/app/app.conf 中写入一行 env=lab,用模块读取这两个路径的状态,并以五个字段保存到 /root/ansmod/out/modernize.json——dir_mode、dir_isdir、conf_mode、conf_size、conf_checksum_len(校验和字符串的长度)。
  7. 创建 /root/ansmod/bootstrap.yml。用 ansible.builtin.raw 运行 command -v python3 || echo NOPYTHON 并 register,把去掉行尾空白和 CR 之后的路径,以一行保存到 /root/ansmod/out/raw.txt(加上 changed_when: false)。并且把到目前为止创建的五个 playbook(boundary.yml、report.yml、pipe.yml、modernize.yml、bootstrap.yml)中的所有 task 整理成使用以 ansible.builtin. 开头的 FQCN。
  8. 先创建将作为检查对象的 /root/ansmod/legacy.yml——共有四个 task:一个用 command 运行 ansible --version 并加上 changed_when: false,一个用 shell 运行 mkdir -p /root/ansmod/legacy/logs,一个用 shell 运行 echo seeded > /root/ansmod/legacy/logs/stamp.txt,一个用 command 运行 ls /root/ansmod/legacy/logs(后三个不加 guard)。然后创建 /root/ansmod/shell-audit.sh <플레이북경로>(占位符为 playbook 路径):对于该 playbook 中使用 command 或 shell 且没有 changed_when 的 task,只输出其名称,每行一个,按字典序排列(必须同时识别短名称和 FQCN)。最后把 ./shell-audit.sh /root/ansmod/legacy.yml 的输出保存到 /root/ansmod/out/audit.txt。

参考

写出目标,并在文档中查找模块

创建 /root/ansmod/hosts.ini——在 [web] 组中放入 web1 和 web2,两者都带有 ansible_host=127.0.0.1 和 ansible_port=2222,并通过 [all:vars] 设置 ansible_user=root。然后把 ansible-doc -s ansible.builtin.command 的输出保存到 /root/ansmod/out/doc-command.txt,把 ansible-doc -s ansible.builtin.shell 的输出保存到 /root/ansmod/out/doc-shell.txt。

必须先有 inventory,才能开始任何操作。这个 Pod 的 sshd 运行在 127.0.0.1:2222,即使写两个主机,也都连到同一个 sshd。ansible-doc 不是互联网搜索,而是当前这台机器上已安装内容的清单。-s 会输出可以粘贴进 playbook 的骨架。把两个文件并排放在一起,找出只在一边出现的选项名称——它恰好展示了两个模块在性质上的差别。

把同样的命令分别用 command 和 shell 抛出去,测量差别

在 /root/ansmod/files/ 中创建三个空文件 a.txt、b.txt、c.txt。创建 /root/ansmod/boundary.yml,在 web1 上运行四个 task——用 command 运行一次 ls /root/ansmod/files/*.txt(即使失败也继续),用 shell 运行一次 ls /root/ansmod/files/*.txt | wc -l,用 command 运行一次 echo one two three | wc -w,再用 shell 运行一次同样的命令。把四个结果恰好写成四行,保存到 /root/ansmod/out/boundary.txt:glob command rc=<값> / glob shell stdout=<값> / pipe command stdout=<값> / pipe shell stdout=<값>(占位符均为值)。

command 会把收到的字符串拆成单词,原样交给可执行文件——中间没有 shell,所以 glob 和管道都不会按 shell 语法来解释。glob 会因命令以非 0 的退出码失败而马上被注意到,但管道会假装成功。把这个差别用数字记录下来,就是这一步的全部。要让 playbook 不因失败的 task 而停止,就加上 ignore_errors: true,因为只是查询,所以也同时加上 changed_when: false。

用 register 接收模块返回的 JSON 并读取

创建 /root/ansmod/report.yml。在 web1 上用 ansible.builtin.command 运行 id -un,以 who 进行 register,并从其返回值中只取出四个字段,以 JSON 保存到 /root/ansmod/out/result.json——rc、stdout、changed 直接使用返回值,cmd 是把返回值的参数列表用空格连接起来的字符串。这一步不加 changed_when。

模块的返回值不是字符串,而是带键的 JSON,register 会把这份 JSON 整个放进变量。里面有 rc、stdout、stdout_lines、stderr、changed、failed、cmd——cmd 是实际执行的参数列表,要变成字符串,必须把它连接起来。有一个过滤器可以把字典直接变成 JSON 字符串。另外,请亲眼确认,这个 task 明明什么都没有改变,changed 显示的是什么——这是下一步的出发点。

用 changed_when 和 failed_when 亲自建立报告标准

在 /root/ansmod/report.yml 中再加入两个 task。一个用 command 运行 cat /etc/hostname,以 hn 进行 register,并加上 changed_when: false。另一个用 shell 运行 grep -c "^nosuchuser:" /etc/passwd,以 hits 进行 register,并在加上 changed_when: false 的同时,加上只有在退出码既不是 0 也不是 1 时才算失败的 failed_when。然后在 /root/ansmod/out/result.json 中再添加三个字段——hostname_changed(hn 的 changed)、grep_rc(hits 的 rc)、grep_failed(hits 的 failed)。playbook 必须一直运行到结束。

grep -c 在什么都没找到时会返回退出码 1。那不是错误,而是“0 条”这个答案,但默认判定是不为 0 就失败,所以 playbook 会在那里停下。failed_when 就是由人重新书写失败定义的地方,在条件表达式中可以直接使用刚刚 register 的变量。请比较加了 changed_when: false 的 task 与没加的 task,它们的 changed 值在 result.json 中有什么不同。

用数字确认管道会吞掉失败,并加以防止

创建 /root/ansmod/pipe.yml。把同一条管道 cat /root/ansmod/missing.txt | wc -l 运行两次——一次直接用 shell(以 bare 进行 register),一次在前面加上 set -o pipefail 并把 executable 指定为 /bin/bash(以 guarded 进行 register)。两者都加上 ignore_errors: true 和 changed_when: false。把结果以四个字段保存到 /root/ansmod/out/pipe.json——bare_rc、bare_failed、guarded_rc、guarded_failed。不要创建 missing.txt。

在 shell 中,管道的退出码是最后一条命令的退出码。即使前面的 cat 失败了,只要 wc 以 0 结束,整体就是 0,这个 task 就会以绿色通过。set -o pipefail 会在管道中任何一个命令失败时,让整体失败。不过默认的 shell(/bin/sh)可能不认识这个选项,所以必须明确指定 shell。如果两个 rc 值出现不同,就成功了——这个差别,就是 ansible-lint 的 risky-shell-pipe 规则存在的原因。

把三行 shell 改用 file、copy、stat 模块

创建 /root/ansmod/modernize.yml。一次都不使用 command 和 shell,完成以下事项——以权限 0750 创建 /root/ansmod/app 目录,以权限 0640 在 /root/ansmod/app/app.conf 中写入一行 env=lab,用模块读取这两个路径的状态,并以五个字段保存到 /root/ansmod/out/modernize.json——dir_mode、dir_isdir、conf_mode、conf_size、conf_checksum_len(校验和字符串的长度)。

有一个模块可以一次性代替 mkdir -p 和 chmod,有一个模块可以代替 echo >,还有一个模块可以代替 ls -l 或 stat 命令。如果想不起这三个名称,请用 ansible-doc -l ansible.builtin | grep -i <낱말>(占位符为词语)去找——这就是第 1 步要把文档提取出来的原因。读取状态的模块,其返回值在名为 stat 的键之下包含 mode、isdir、size、checksum。权限是字符串,漏掉引号的话,八进制会被当作十进制读取。

用 raw 查找 Python,并把 playbook 整理为 FQCN

创建 /root/ansmod/bootstrap.yml。用 ansible.builtin.raw 运行 command -v python3 || echo NOPYTHON 并 register,把去掉行尾空白和 CR 之后的路径,以一行保存到 /root/ansmod/out/raw.txt(加上 changed_when: false)。并且把到目前为止创建的五个 playbook(boundary.yml、report.yml、pipe.yml、modernize.yml、bootstrap.yml)中的所有 task 整理成使用以 ansible.builtin. 开头的 FQCN。

raw 通过 SSH 原样抛出字符串,并原样接收输出——所以即使目标上没有 Python 也能运行,但收到的字符串上会原样带着 CR 和换行。有一个 Jinja 过滤器可以去掉两端的空白。文件中是否还残留 CR,可以用 od -c 来确认。整理 FQCN 不是靠手,而是靠眼睛——用 yq -r '.[].tasks[] | keys | .[]' <파일>(占位符为文件)把 task 使用的全部键提取出来,短名称就一目了然。

找出没有 guard 就交给 shell 的 task 的审计工具

先创建将作为检查对象的 /root/ansmod/legacy.yml——共有四个 task:一个用 command 运行 ansible --version 并加上 changed_when: false,一个用 shell 运行 mkdir -p /root/ansmod/legacy/logs,一个用 shell 运行 echo seeded > /root/ansmod/legacy/logs/stamp.txt,一个用 command 运行 ls /root/ansmod/legacy/logs(后三个不加 guard)。然后创建 /root/ansmod/shell-audit.sh <플레이북경로>(占位符为 playbook 路径):对于该 playbook 中使用 command 或 shell 且没有 changed_when 的 task,只输出其名称,每行一个,按字典序排列(必须同时识别短名称和 FQCN)。最后把 ./shell-audit.sh /root/ansmod/legacy.yml 的输出保存到 /root/ansmod/out/audit.txt。

这一步是让工具来追问“这个 shell 真的有必要吗”,而不是每次评审都靠人来问。如果工具因为写法不同就视而不见,那就不是审计——shell: 和 ansible.builtin.shell: 必须被视为同一个东西。镜像中的 yq 是 mikefarah 版,所以用 .[].tasks[] 沿着 play 列表深入,含有点的键要像 .["ansible.builtin.shell"] 这样用方括号书写。询问不存在的键会得到 null,所以用 select(... != null) 过滤即可。