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

策略即代码

退出码是 0,于是流水线连续几周都亮着绿灯

在 TT Lab 中继续学习

目标

把真实的 OpenTofu 计划导出为 JSON 并读懂其结构,对这份 JSON 施加策略,在触及集群和云之前抓住销毁、替换和缺失的标签。并且在一个找到违规却以 0 结束的工具之上,构建可以信赖的门禁。

为什么重要

准入控制看的是已经构造好的请求。可是像替换数据库、公开存储桶这样的危险变更,不会经过 Kubernetes API,而是直接走向云。这些变更有一个共同点——应用之前有一个计划阶段,而这份计划是可以导出为 JSON 的结构化文档。对它施加策略,就能在还没有什么需要回退的时候拦住。不过,计划中混有只有应用之后才能确定的值,如果不了解这个位置就去编写规则,就会产生大量误报,门禁几天之内就会被关掉。而且如果把判定交给工具的退出码,仅仅因为有一个找到违规却以 0 结束的工具,流水线就可能在好几周里悄悄地亮着绿灯。所以本实验讲的“依据什么来判定”,与编写规则的方法同等重要。

步骤

  1. 在 /root/tfpolicy/main.tf 中要求 local、random、null provider,并声明四个资源。null_resource.api 的 triggers 为 owner = "platform"、env = "dev",null_resource.worker 的 triggers 为 owner = "data"、env = "dev",local_file.config 向 ${path.module}/out/config.txt 写入一行 v1(末尾带换行),terraform_data.release 的 input 为 v1。用 tofu init 和 tofu apply -auto-approve 应用之后,把没有任何变更的计划保存为 /root/tfpolicy/base.tfplan,并用 tofu show -json 生成 /root/tfpolicy/base.json。
  2. 把 main.tf 修改如下。删除 null_resource.worker 块,加入 random_pet.suffix(length = 2),加入 null_resource.cache(triggers 为 owner = ""、env = "dev"、name = random_pet.suffix.id)。把 local_file.config 的内容改为一行 v2,把 terraform_data.release 的 input 改为 v2。不要应用。把计划保存为 /root/tfpolicy/change.tfplan,生成 /root/tfpolicy/change.json 之后,在 /root/tfpolicy/changes.txt 中,对每个不是 no-op 的资源,以 <주소> <create|update|replace|delete>(占位符为地址)写一行。如果同一个资源同时包含删除和创建,就写成 replace 一行。
  3. 读取 /root/tfpolicy/change.json 的 resource_changes[].change.after_unknown,生成 /root/tfpolicy/unknown.txt。对每个至少有一个未知叶子(值为 true 的位置)的资源,以 <주소> <모르는 잎 개수>(占位符依次为地址与未知叶子个数)写一行。像 triggers.name 这样嵌套的位置,也算作一个叶子。没有未知叶子的资源不写。行的顺序不看。
  4. 在 /root/tfpolicy/policies/block.yaml 中编写 apiVersion: json.kyverno.io/v1alpha1、kind: ValidatingPolicy 的策略。只放一条规则,要求change.actions 中含有 delete 的资源一个都不能有才算通过(单纯删除和替换都会被抓住)。然后运行 KYVERNO_EXPERIMENTAL=true kyverno json scan --payload /root/tfpolicy/change.json --policy /root/tfpolicy/policies/block.yaml,把输出的全部内容和退出码保存到 /root/tfpolicy/scan-exit.txt。退出码以 exit=<코드> 的形式(占位符为退出码),在最后追加一行。对 /root/tfpolicy/base.json 也运行同样的扫描,亲眼确认它能通过。
  5. 创建 /root/tfpolicy/gate.sh <계획JSON>(占位符为计划 JSON)。用 policies/block.yaml 运行扫描并解析报告。对每条违规行(含有 FAILED 或 ERROR: 的行),在前面加上 BLOCK 后各输出一行,并在最后一行输出 RESULT block=<위반 줄 수>(占位符为违规行数;后面可以再附加其他词)。没有违规则以 0 结束,只要有一条就以非 0 的值结束。如果报告中一条判定行(PASSED、FAILED、ERROR:)都没有,说明工具的输出变了,就以 2 结束;作为参数接收的文件不存在时,也以 2 结束。评分器会用它自己制作的干净计划和违规计划来运行这个脚本。
  6. 在 /root/tfpolicy/policies/block.yaml 中加入第二条规则。有 change.after.triggers 的资源,change.after.triggers.owner 必须存在且不是空字符串才算通过。不过,该值在计划阶段尚未确定的资源(change.after_unknown.triggers.owner 为 true),不算作违规。加上规则之后,重新运行 ./gate.sh /root/tfpolicy/change.json,确认违规变成两行。评分器会用 owner 为空的计划、owner 为尚未知晓的值的计划、干净的计划这三种来测试这条规则。
  7. 在 /root/tfpolicy/policies/warn.yaml 中创建第二个策略文件。只放一条规则,要求计划 JSON 最顶层的 resource_drift 为空才算通过(连键本身都没有的计划也必须通过)。然后修改 /root/tfpolicy/gate.sh,让它分别运行两个策略。block.yaml 的违规要加上 BLOCK 后输出,并使退出码不为 0;warn.yaml 的违规要加上 WARN 后输出,但不改变退出码。最后一行改为 RESULT block=<차단 위반 수> warn=<경고 위반 수>(占位符依次为拦截违规数与警告违规数)。如果警告策略的报告中也一条判定行都没有,就以 2 结束。
  8. 在 Terraform 之外直接修改 /root/tfpolicy/out/config.txt(例如 printf 'hand-edited\n' > /root/tfpolicy/out/config.txt)。然后把计划保存为 /root/tfpolicy/drift.tfplan,并生成 /root/tfpolicy/drift.json——最顶层会出现 resource_drift。最后对 base.json、change.json、drift.json 三份计划运行 ./gate.sh,把输出分别保存到 /root/tfpolicy/reports/base.txt、/root/tfpolicy/reports/change.txt、/root/tfpolicy/reports/drift.txt,并在每个文件的最后一行追加 EXIT <게이트 종료 코드>(占位符为门禁退出码)。评分器会对这三份计划重新运行门禁,并与报告核对。

参考

把计划导出为 JSON

在 /root/tfpolicy/main.tf 中要求 local、random、null provider,并声明四个资源。null_resource.api 的 triggers 为 owner = "platform"、env = "dev",null_resource.worker 的 triggers 为 owner = "data"、env = "dev",local_file.config 向 ${path.module}/out/config.txt 写入一行 v1(末尾带换行),terraform_data.release 的 input 为 v1。用 tofu init 和 tofu apply -auto-approve 应用之后,把没有任何变更的计划保存为 /root/tfpolicy/base.tfplan,并用 tofu show -json 生成 /root/tfpolicy/base.json。

local、random、null 可以从 Pod 内的 mirror 获取,所以没有网络也能 init。terraform_data 是无需 provider 就能使用的内置资源。刚应用完成之后制定的计划,所有资源的 actions 都是 no-op——这正是测试策略时要用的干净载荷。计划文件用 -out 保存,JSON 则是把该文件交给 tofu show -json 得到的。

一份计划中同时出现四种动作

把 main.tf 修改如下。删除 null_resource.worker 块,加入 random_pet.suffix(length = 2),加入 null_resource.cache(triggers 为 owner = ""、env = "dev"、name = random_pet.suffix.id)。把 local_file.config 的内容改为一行 v2,把 terraform_data.release 的 input 改为 v2。不要应用。把计划保存为 /root/tfpolicy/change.tfplan,生成 /root/tfpolicy/change.json 之后,在 /root/tfpolicy/changes.txt 中,对每个不是 no-op 的资源,以 <주소> <create|update|replace|delete>(占位符为地址)写一行。如果同一个资源同时包含删除和创建,就写成 replace 一行。

tofu show -json 结果的 resource_changes[] 中有 address 和 change.actions。如果 actions 是含有两个元素的数组,就是替换——把它当作一次新增加一次删除,算成两次,就错了。哪个属性会导致替换由 provider 决定,所以也可以通过计划输出中的 # forces replacement 标记来确认。行的顺序评分时不看。

统计计划目前还不知道的值

读取 /root/tfpolicy/change.json 的 resource_changes[].change.after_unknown,生成 /root/tfpolicy/unknown.txt。对每个至少有一个未知叶子(值为 true 的位置)的资源,以 <주소> <모르는 잎 개수>(占位符依次为地址与未知叶子个数)写一行。像 triggers.name 这样嵌套的位置,也算作一个叶子。没有未知叶子的资源不写。行的顺序不看。

after_unknown 是与 after 结构相同、只保留未知叶子为 true、已知叶子则完全省略的对象。jq 的 paths(조건)(占位符为条件)会给出所有满足条件的值的路径——可以用 [paths(. == true)] | length 来统计叶子。编写策略时这个位置之所以重要,是因为如果仅因为 after 中没有值,就立即断定为违规,会产生误报。

把规则写成数据,并打印退出码

在 /root/tfpolicy/policies/block.yaml 中编写 apiVersion: json.kyverno.io/v1alpha1、kind: ValidatingPolicy 的策略。只放一条规则,要求change.actions 中含有 delete 的资源一个都不能有才算通过(单纯删除和替换都会被抓住)。然后运行 KYVERNO_EXPERIMENTAL=true kyverno json scan --payload /root/tfpolicy/change.json --policy /root/tfpolicy/policies/block.yaml,把输出的全部内容和退出码保存到 /root/tfpolicy/scan-exit.txt。退出码以 exit=<코드> 的形式(占位符为退出码),在最后追加一行。对 /root/tfpolicy/base.json 也运行同样的扫描,亲眼确认它能通过。

kyverno-json 的 assert.check 中,键是 JMESPath 表达式,值是预期值——在用括号包起来的键 (식)(占位符为表达式)上放置预期值 0,就表示“这个表达式的结果必须是 0”。数组中是否含有某个字符串,用 contains(배열, '값')(占位符依次为数组与值)来询问。如果想单独测试表达式,使用 kyverno jp query -i <파일> '<식>'(占位符依次为文件与表达式)。同时保存输出和退出码时,명령 > 파일 2>&1; echo "exit=$?" >> 파일(占位符依次为命令、文件、文件)这种形式很方便。

丢掉退出码,改为统计报告

创建 /root/tfpolicy/gate.sh <계획JSON>(占位符为计划 JSON)。用 policies/block.yaml 运行扫描并解析报告。对每条违规行(含有 FAILED 或 ERROR: 的行),在前面加上 BLOCK 后各输出一行,并在最后一行输出 RESULT block=<위반 줄 수>(占位符为违规行数;后面可以再附加其他词)。没有违规则以 0 结束,只要有一条就以非 0 的值结束。如果报告中一条判定行(PASSED、FAILED、ERROR:)都没有,说明工具的输出变了,就以 2 结束;作为参数接收的文件不存在时,也以 2 结束。评分器会用它自己制作的干净计划和违规计划来运行这个脚本。

这一步的关键是不使用 if kyverno json scan ...; then——那个工具即使找到违规,也会以 0 结束。把输出放进变量,用 grep -E 统计,再根据统计值是否为 0 来结束。必须区分“违规为 0 条”这个判定与“什么都没能读到”这个判定——工具一升级,就会出现看上去像前者的后者。set -e 会在 grep 什么都没找到时让脚本先死掉,所以不要使用。

要求标签,结果连还不知道的值也被撞上了

在 /root/tfpolicy/policies/block.yaml 中加入第二条规则。有 change.after.triggers 的资源,change.after.triggers.owner 必须存在且不是空字符串才算通过。不过,该值在计划阶段尚未确定的资源(change.after_unknown.triggers.owner 为 true),不算作违规。加上规则之后,重新运行 ./gate.sh /root/tfpolicy/change.json,确认违规变成两行。评分器会用 owner 为空的计划、owner 为尚未知晓的值的计划、干净的计划这三种来测试这条规则。

在 JMESPath 的过滤器中,JSON 字面量用反引号书写——`true`、`null`。读取不存在的键会得到 null,所以要同时询问“键不存在”和“是空字符串”。而且如果不看 after_unknown,从数据源计算值的模块就会整个被判为违规,几天之内门禁就被关掉了。要整体测试一条规则,用 kyverno jp query -i <계획JSON> '<식>'(占位符依次为计划 JSON 与表达式)最快。

有些规则拦截,有些规则只是通知

在 /root/tfpolicy/policies/warn.yaml 中创建第二个策略文件。只放一条规则,要求计划 JSON 最顶层的 resource_drift 为空才算通过(连键本身都没有的计划也必须通过)。然后修改 /root/tfpolicy/gate.sh,让它分别运行两个策略。block.yaml 的违规要加上 BLOCK 后输出,并使退出码不为 0;warn.yaml 的违规要加上 WARN 后输出,但不改变退出码。最后一行改为 RESULT block=<차단 위반 수> warn=<경고 위반 수>(占位符依次为拦截违规数与警告违规数)。如果警告策略的报告中也一条判定行都没有,就以 2 结束。

resource_drift 是计划告知有人在代码之外手动修改了什么的位置。对完全没有这个键的计划调用 length() 会报错,所以像 resource_drift || []`` 这样给出默认值更安全。门禁一侧运行两次扫描,分别保留统计值,退出码只根据拦截一侧的数字来决定。在实际工作中启用新规则的顺序也是这样——先用警告统计几天,等撞上的都整理完了,再移到拦截。

一个被手动修改的文件被抓成了漂移

在 Terraform 之外直接修改 /root/tfpolicy/out/config.txt(例如 printf 'hand-edited\n' > /root/tfpolicy/out/config.txt)。然后把计划保存为 /root/tfpolicy/drift.tfplan,并生成 /root/tfpolicy/drift.json——最顶层会出现 resource_drift。最后对 base.json、change.json、drift.json 三份计划运行 ./gate.sh,把输出分别保存到 /root/tfpolicy/reports/base.txt、/root/tfpolicy/reports/change.txt、/root/tfpolicy/reports/drift.txt,并在每个文件的最后一行追加 EXIT <게이트 종료 코드>(占位符为门禁退出码)。评分器会对这三份计划重新运行门禁,并与报告核对。

漂移是状态文件记住的值与实际值出现了偏差,会在制定计划时的刷新中暴露出来。生成报告时,门禁的退出码必须在重定向之后立刻用 $? 获取——中间哪怕插入一条别的命令,得到的就是那条命令的退出码。三份报告的 RESULT 行结果各不相同,就是本实验的结论:干净、拦截、警告,由同一个门禁区分开来。