Conftest 配置策略测试工程手册
一条基础设施流水线连续数周显示 Conftest 通过,生产评审却发现明令禁止的公网端口已经写进配置。团队最初以为规则条件有漏洞,查看 JSON 输出才发现 0 tests:策略包使用 package kubernetes.admission,命令仍按默认 main namespace 查询。进程退出码是零,但预期规则从未进入查询结果;“命令成功”与“目标规则执行过”必须用不同证据证明。
另一个项目在开发机上能稳定拒绝错误 Terraform 配置,CI 却把同一文件放行。两边 Conftest 版本相同,差异来自输入:本地按 .tf 自动选择 HCL2 parser,CI 为了统一 YAML 检查加了全局 --parser yaml,策略看到的字段形状完全不同。Parser 决定 Rego 的 input 契约,不是一个无害的文件读取选项;扩展名、stdin、--combine 和渲染阶段的任一变化,都可能让规则在错误数据结构上运行。
Conftest 检查结构化配置,不接管运行时授权
Conftest 是 shift-left CLI:它选择 parser,把 YAML、JSON、HCL2、Dockerfile 等配置转换成结构化 input,加载 Rego policy 与辅助 data,再执行约定 namespace 下的 deny/violation、warn 和 exception 规则。它适合在提交前或 CI 中检查配置仓库,不是常驻 PDP,不是 Kubernetes admission webhook,也不会替目标系统执行拒绝动作。
Parser 成功只说明源文本能转换,不说明目标配置有效。YAML 能解析不等于 Kubernetes schema 合法;HCL2 能解析不等于 Terraform provider schema 正确,也不代表变量、模块和表达式已经完成求值。一个可靠流水线通常分层执行:格式与语法、目标工具 schema/validate、渲染、Conftest 组织策略、部署前 diff。Conftest 的位置由想保护的对象决定。
例如 Helm chart 可以检查三层:源 values.yaml 适合限制团队输入;渲染后的 manifest 适合检查最终资源;集群 admission 负责防止绕开 CI 的提交。只检查 values 会漏掉模板默认值,只检查渲染结果则可能丢失输入来源。团队可以两处都跑,但规则名和结果必须标明 pre-render 或 post-render,避免把同一违规计数两次。
安装后先记录真正执行的版本
从 Conftest Releases 选择审核过的明确版本。官方提供 release 二进制、Homebrew、Scoop、Mise、Go install 与容器镜像;包管理器的版本新鲜度可能不同,安装后以 conftest --version 和制品摘要为准。Conftest 内嵌 OPA,系统中另装的 opa version 不能证明 Conftest 使用相同 Rego 运行时。
scoop install conftest
conftest --version
Get-Command conftest | Select-Object -ExpandProperty Sourcebrew install conftest
conftest --version
CONFTEST_VERSION='<approved-version>'
CGO_ENABLED=0 go install "github.com/open-policy-agent/conftest@v${CONFTEST_VERSION}"
"$(go env GOPATH)/bin/conftest" --version容器使用 openpolicyagent/conftest。旧的 instrumenta/conftest 已停止更新,迁移时要一起清理流水线模板、开发机缓存和 pre-commit 环境,不能只改一段说明文字。
CONFTEST_VERSION='<approved-version>'
docker pull "openpolicyagent/conftest:v${CONFTEST_VERSION}"
docker image inspect "openpolicyagent/conftest:v${CONFTEST_VERSION}" \
--format '{{index .RepoDigests 0}}'
docker run --rm -v "$PWD:/project" -w /project \
"openpolicyagent/conftest:v${CONFTEST_VERSION}" --versionCI 最终固定镜像 digest,而不是只固定可移动 tag。开发机可以通过版本管理器安装,但仓库脚本应在运行前打印 Conftest 版本、配置来源、policy revision 与 parser 模式,让本地失败能够在 runner 上复现。
第一个闭环:让允许与拒绝都可观察
创建下面三个文件。策略采用 Rego v1,并把 namespace 设为 main,与 Conftest 默认值一致。
conftest-lab/
├── policy/
│ ├── deployment.rego
│ └── deployment_test.rego
├── deployment-ok.yaml
└── deployment-bad.yamlpackage main
deny contains msg if {
input.kind == "Deployment"
not input.spec.template.spec.containers[0].resources.limits.memory
msg := "Deployment 的第一个容器必须设置 memory limit"
}
warn contains msg if {
input.kind == "Deployment"
input.metadata.labels.owner == "unknown"
msg := "owner 标签不能使用 unknown"
}policy/deployment_test.rego 直接给规则注入两个结构化输入,确保 verify 不是零测试空跑:
package main
test_accepts_memory_limit if {
results := deny with input as {
"kind": "Deployment",
"spec": {"template": {"spec": {"containers": [
{"resources": {"limits": {"memory": "256Mi"}}}
]}}}
}
count(results) == 0
}
test_denies_missing_memory_limit if {
results := deny with input as {
"kind": "Deployment",
"spec": {"template": {"spec": {"containers": [{}]}}}
}
"Deployment 的第一个容器必须设置 memory limit" in results
}apiVersion: apps/v1
kind: Deployment
metadata:
name: api
labels:
owner: platform
spec:
template:
spec:
containers:
- name: api
image: example/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
resources:
limits:
memory: 256Mideployment-bad.yaml 保持其余内容不变,只删除 resources.limits.memory。先验证 policy tests,再分别执行正反输入:
cd conftest-lab
conftest verify --policy ./policy
conftest test --output=json --policy ./policy deployment-ok.yaml > ok.json
conftest test --output=json --policy ./policy deployment-bad.yaml > bad.json第二条应退出 0,JSON 中没有 failure;第三条应非零退出,并包含 Deployment 的第一个容器必须设置 memory limit。Shell 开启 set -e 时,若想保存反例输出,必须显式捕获退出码:
set +e
conftest test --output=json --policy ./policy deployment-bad.yaml > bad.json
status=$?
set -e
test "$status" -ne 0
jq -e 'map(.failures | length) | add > 0' bad.json清理只需删除实验目录;容器模式没有常驻服务、端口或 volume 状态,但镜像缓存仍占磁盘。共享 runner 是否清理镜像由缓存策略决定,不要在仓库脚本里无差别删除其他任务使用的缓存。
Parser 决定 input 长什么样
默认情况下,Conftest 按每个文件的名称和扩展名选择 parser:.yaml/.yml 使用 YAML,.tf/.tfvars/.hcl 使用 HCL2,Dockerfile 命名走 Dockerfile parser,stdin 路径 - 默认按 YAML。Conftest Parser 文档列出了当前识别规则与显式覆盖入口;可用 parser 会随版本演进,升级前仍要通过 conftest parse --help 和目标版本行为确认,不能把一份静态列表当永久合同。
先把 parser 输出保存成 golden fixture,而不是凭源文件外观猜字段路径:
conftest parse deployment-ok.yaml > fixtures/deployment-ok.input.json
cat deployment-ok.yaml | conftest parse - > fixtures/deployment-stdin.input.json
conftest parse main.tf > fixtures/main-tf.input.json
git diff --exit-code -- fixtures/--parser hcl2 会强制所有输入都使用 HCL2,不是只在自动识别失败时 fallback。混合 YAML 与 Terraform 文件时全局加 --parser yaml,应让 Terraform 解析失败;若流水线静默跳过该文件,任务发现逻辑本身已经有缺陷。
反向实验可以把合法 YAML 复制为 deployment.json,先让自动 parser 失败,再显式指定 YAML:
cp deployment-ok.yaml deployment.json
set +e
conftest test --output=json --policy ./policy deployment.json > wrong-extension.json 2>&1
status=$?
set -e
test "$status" -ne 0
conftest test --output=json --policy ./policy \
--parser yaml deployment.json > forced-yaml.json
rm deployment.json这个实验不是鼓励长期依靠强制 parser 修补错误扩展名。更稳妥的做法是规范文件命名,并让 CI 对未知或错配扩展名失败;显式 parser 只在 stdin、无扩展名产物或固定格式管道中使用。
多文件 combine 会改变整个策略合同
普通模式按文件或文档执行策略,规则常见写法是 input.kind。--combine 会把所有输入合成一个数组,每项包含 path 与 contents:
[
{
"path": "deployment.yaml",
"contents": { "kind": "Deployment" }
}
]此时规则要访问 input[i].contents.kind。它适合检查 Service 与 Deployment 配对、跨文件重复值或引用关系,不应为了“覆盖更多文件”全局打开。普通策略直接在 combine 模式下可能得到零命中而不是预期拒绝,因此 combine 应有独立 namespace 和 fixture:
package combined
deployment_names contains item.contents.metadata.name if {
some item in input
item.contents.kind == "Deployment"
}
service_names contains item.contents.metadata.name if {
some item in input
item.contents.kind == "Service"
}
deny contains msg if {
some name in service_names
not name in deployment_names
msg := sprintf("Service %s 没有同名 Deployment", [name])
}conftest test --output=json --combine \
--namespace combined --policy ./policy-combined \
service.yaml deployment.yaml > combined.jsonYAML 多文档、多个文件和 combine 的 path 值都要进入 golden fixture。实现会对 combined configurations 做稳定处理,但业务规则不要依赖没有明示的输入顺序;按 path 或业务标识建立映射更可靠。
Conftest 还提供 parse_config、parse_config_file、parse_combined_config_files built-ins,Rego unit test 可以复用 CLI parser。涉及 parser built-in 时开启 --show-builtin-errors,否则错误可能表现成规则未命中;parse_config_file 会做磁盘 I/O,大量 fixture 应预解析或按测试层级拆开,避免把策略测试变成慢速文件扫描。
Namespace 是查询合同,不是文件夹别名
Conftest 默认 namespace 是 main。如果 policy 写成 package platform.kubernetes,命令必须显式使用对应 namespace,或在 conftest.toml 中固定:
policy = "policy"
namespace = "platform.kubernetes"配置优先级是 CLI flags 高于 CONFTEST_* 环境变量,高于工作目录中的 conftest.toml。CI 从子目录运行、环境里遗留 CONFTEST_NAMESPACE、脚本又传入 --namespace 时,实际查询可能与开发机不同。启动日志应打印工作目录和显式参数;关键流水线不依赖 runner 全局环境隐式改变配置。
针对“零测试却退出零”的反例,不能只断言 exit code。固定 JSON 输出后,检查结果中至少出现目标文件和预期规则族;再准备一个必定违规的 canary fixture,证明每次 CI 都能得到非零退出和固定 reason。若 canary 突然通过,立即阻断流水线,它能同时暴露 namespace、policy path、parser 与任务发现错误。
多个 --policy/-p 参数会把目录合并看待,不保证后一个目录覆盖前一个。组织基线与项目 policy 出现同名 complete rule 时,可能冲突或改变结果。推荐按 package 分层,例如 org.security 与 project.delivery,由 CI 分别执行并汇总;若确实组合运行,升级前做完整 fixture 回归,禁止依靠加载顺序制造覆盖。
--data/-d 递归加载 JSON/YAML 为 data,适合保存团队允许列表、环境分类和例外记录。被测文件属于 input,辅助事实属于 data,两者目录和评审责任应分开。例外至少包含 owner、reason、适用 rule、目标资源、相对有效期表达和撤销状态;规则要拒绝无 owner、通配对象和已失效例外,避免 exception 变成永久旁路。
Bundle 共享要固定不可变 revision
Conftest 通过 OPA Bundle 机制分发策略与数据,Conftest Sharing Policies 文档给出了远程策略与 Bundle 的接入方式。共享入口解决的是分发,不会自动保证发布原子性、来源可信或版本可追溯;生产门禁仍要把下载地址、摘要、签名验证结果和激活 revision 绑定在同一次运行记录中。
小项目可以把 policy 放在同一仓库,大团队往往需要共享组织基线。Conftest 支持 OPA Bundle,并可从 HTTPS、Git 或 OCI 获取策略。共享链路应先由受控流水线构建、测试并发布不可变 Bundle,再让项目锁定 revision 或 digest;不要在每次 CI 中无审计执行 pull --update 获取“最新规则”。
一个可审计流程是:
policy source commit
-> conftest verify 与正反 fixture
-> opa build 生成 Bundle
-> 计算 digest 并发布 OCI/HTTPS 制品
-> 项目锁文件记录 digest
-> CI 拉取到隔离缓存目录
-> conftest 按锁定 Bundle 执行拉取凭证只需要读取指定仓库,使用 CI Secret、短期身份或 credential helper。不要把 Token 放进会回显的 Git/HTTPS URL,也不要把私有被测配置上传到公共 policy 服务。缓存目录按 digest 分开,下载完成后先校验摘要,再原子切换项目指针;失败时继续使用锁文件指定且已验证的旧缓存,不能悄悄改为无 policy 运行。
Bundle 升级需要比较的不只是规则输出。还要保存 Conftest 版本、其内嵌 OPA 依赖、parser golden、namespace、辅助 data digest、warning 契约和 Bundle digest。策略更新与 Conftest 二进制更新分成两次变更,才能在结果变化时判断是规则、parser 还是运行时语义导致。
本地、pre-commit 与 CI 使用同一个入口
仓库提供一个脚本作为唯一入口,参数明确指出检查阶段和文件集合。pre-commit 只跑受影响的小集合以控制等待时间,CI 跑完整集合并保存 JSON/SARIF 等机器报告;两处调用同一个镜像 digest、policy lock、namespace 和 parser 规则。
#!/usr/bin/env bash
set -euo pipefail
: "${CONFTEST_IMAGE:?CONFTEST_IMAGE must be an immutable image reference}"
files=("$@")
if [ "${#files[@]}" -eq 0 ]; then
mapfile -t files < <(git ls-files '*.yaml' '*.yml' '*.json' '*.tf' 'Dockerfile*')
fi
docker run --rm \
--network none \
--read-only \
-v "$PWD:/project:ro" \
-w /project \
"$CONFTEST_IMAGE" test \
--output=json \
--policy ./policy \
"${files[@]}"这是可执行脚本,不包含名为 policy-check 一类并不存在的语义命令。容器 --network none 能减少被测配置和 policy 外传面,但前提是 Bundle 已在运行前拉到仓库或只读缓存;若运行阶段确需网络拉取,应单独分成 fetch job,并限制目的地址和凭证。
pre-commit 配置调用该脚本时要考虑文件名中的空格与删除文件,优先让框架逐个传参,不用字符串拼接 shell。CI 则先运行目标工具自己的 validate/render,再把最终产物路径传给脚本。例如 Kubernetes 流程先做 schema 验证和 Helm/Kustomize 渲染,Conftest 检查渲染目录;Terraform 流程先 fmt、init -backend=false、validate,再决定检查源 HCL 还是机器可读 plan JSON,并为二者使用不同 policy。
Warning 的退出合同必须显式固定。默认 warning 可能不阻断,严格流水线使用 --fail-on-warn 前先清理存量或建立限时例外;升级时分别运行默认模式和严格模式,证明 warning 数量、owner 与退出码符合预期。不要突然把所有 warning 升级为 failure,让团队只能绕过整个门禁。
正反实验要证明机制,不只证明命令能跑
一组可持续回归至少包含四类 fixture:允许、明确拒绝、parser 失败、规则未命中防护。每个 failure 使用稳定 reason/code,避免 CI 只能解析易变的自然语言。
正向:合法 Deployment 在固定 YAML parser、main namespace 和 Bundle revision 下退出 0,JSON 中目标文件存在且 failures 为零。语义反向:删除 memory limit,退出非零,并命中唯一规则 ID;恢复字段后再次退出 0。Parser 反向:把 YAML 内容改成 .json 或对混合输入强制 YAML,必须 parse failure,不能跳过文件。
查询反向:把 namespace 改成不存在值,canary fixture 必须暴露“目标规则未执行”,而不是把空结果当成功。Combine 反向:普通 policy 在 --combine 下先失败或零命中,使用 combine 专用 package 后才通过预期配对测试。Schema 分层反向:加入 Kubernetes 不认识但 YAML 合法的字段;Conftest 若没有对应规则仍可能通过,目标 schema validator 必须拒绝。
conftest verify --policy ./policy 负责执行 policy 自带测试,但它不能替代 CLI 输入回归。测试中若调用 parser built-in,加入 --show-builtin-errors 并准备非法文本,确认错误在 CI 报告中可见。保存 stdout、stderr、退出码、Conftest 版本、policy digest 与 fixture digest,修复后重放同一个失败输入。
失败时先定位输入,再定位规则
| 现象 | 第一证据 | 常见原因 | 再验证 |
|---|---|---|---|
0 tests 或意外通过 | JSON 中的 query/tests、namespace、policy path | package 不匹配、目录错误、规则命名错误 | canary fixture 必须产生固定拒绝 |
| 本地拒绝、CI 通过 | conftest --version、镜像 digest、工作目录、parser、配置来源 | 内嵌 OPA、环境变量或 parser 不同 | 同一 fixture 与 digest 两处输出一致 |
| HCL/YAML 字段找不到 | conftest parse 的实际 JSON | 把源文本路径当成 parser 输出路径 | golden fixture 与规则路径同步更新 |
--combine 后规则消失 | combined input 首层形状 | 仍使用 input.kind | combine package 命中跨文件反例 |
| Policy 拉取偶发变化 | lock、remote digest、缓存目录 | 使用浮动 revision 或 --update | 离线重跑仍得到相同结果 |
| Warning 没阻断 | 参数与退出码 | 未启用 --fail-on-warn 或报告被吞 | warning canary 符合固定退出合同 |
不要看到空结果就给规则加更多条件。先保存 conftest parse 输出,再确认 namespace 与 policy 文件确实加载,最后才检查 Rego 分支。解析错误、未查询和策略允许是三种完全不同的状态,应在 CI 里使用不同 reason 和指标。
权限、敏感配置与供应链风险
Conftest 会读取提交中的配置,这些文件可能含 Secret 引用、内网拓扑、镜像地址、云账号标识或意外明文凭证。Runner 只读挂载仓库,报告按敏感级别保存;策略消息不要回显整个 input,只输出文件、规则 ID 和必要字段路径。公开 PR 的外部贡献代码不能自动获得组织 policy 仓库 Token,来自 fork 的任务要使用无密钥模式或受信合并队列。
Policy Bundle 本身也是可执行治理逻辑。发布身份、Bundle 写权限与项目例外审批分离;签名私钥不进入消费者,读取凭证限定 repository 与环境。第三方 Rego、parser 或镜像升级按供应链依赖审查,固定源码 tag、镜像 digest 和摘要。Conftest 允许 policy 使用 built-ins,团队应审查网络和文件访问能力,避免一个“检查配置”的任务反过来读取 runner 上不相关的 Secret。
例外与 warning 都是长期状态。报告要包含 rule ID、owner、reason、目标对象、开始 revision、失效条件和复查入口;删除配置对象、迁移仓库或 owner 离职时同步回收。对 policy 目录使用 CODEOWNERS 或等价评审规则,禁止修改规则的人同时无复核地批准自己的例外。
容量与成本来自文件数、解析和 runner 扇出
Conftest 没有常驻服务副本,但大仓库仍会遇到容量问题。总耗时近似由文件发现、读取、parser、规则求值、报告序列化和容器启动组成;parse_config_file 的重复磁盘 I/O、--combine 的大数组、巨型 Terraform plan JSON、重复下载 Bundle 和每个目录单起容器都会放大成本。
测量时按文件类型记录数量与字节、parser 时间、policy 求值时间、峰值 RSS、输出大小和 runner 并发。pre-commit 可以只检查变更文件,但跨文件规则必须补受影响闭包或在 CI 全量执行;不能为了速度让 Service 变更不再检查对应 Deployment。CI 缓存按 policy digest 与 Conftest digest 建 key,命中后离线运行,既减少网络成本也避免远端抖动。
并发按 runner CPU/内存预算设置,不让每个 job 同时解析相同全量目录。超大 plan 可按模块分片,但规则若依赖全局唯一性就必须保留汇总阶段。报告体积设置上限时保存摘要和完整制品位置,不要直接截断到看不见失败规则;敏感报告的保留期与访问审计也会产生成本。
无常驻服务,也需要连续性与升级回滚
Conftest 本身不做 HA,连续性落在 runner 池、不可变镜像、policy 制品库和缓存镜像。组织 policy 仓库或 OCI Registry 故障时,锁定版缓存可以继续执行;没有已验证缓存就明确失败,不能降级为跳过。多个 runner 区域应拉到同一 digest,并用 canary fixture 定期证明 parser、namespace 和 warning 契约一致。
升级采用二维矩阵:旧 Conftest + 旧 Bundle、旧 Conftest + 新 Bundle、新 Conftest + 旧 Bundle、新 Conftest + 新 Bundle。先只升级 policy,比较允许、拒绝、warning、exception 和 parser golden;再只升级 Conftest,检查内嵌 OPA 带来的 Rego 语义、built-ins、输出字段和性能变化。把两者一次性替换,会让回归无法归因。
发布前在非阻断 job 影子执行新组合,记录 allow→deny、deny→allow、warning 变化和 parser diff。确认每个差异有 owner 后再切主门禁。回滚只需要把 policy lock 或镜像 digest 恢复到上一审核组合,但仍要重放导致回滚的 fixture;如果新 policy 使用旧运行时不支持的语法,回滚二进制时必须同时回滚 Bundle。
退出时归还门禁责任
停止使用 Conftest 前,先列出它当前阻断的规则、检查阶段、例外和报告消费者。目标工具的 schema validator、另一策略引擎或 admission 层必须接住相应责任,并用同一正反 fixture 做一段时间的双跑。只有新链路能稳定拒绝旧反例、处理 parser 等价输入并产生可追溯 reason 后,才能从 required check 中移除 Conftest。
随后删除浮动 policy 拉取、撤销 Registry/Git 凭证、清理 runner 缓存与过期报告,归档最后使用的 Conftest digest、Bundle digest、parser golden、规则映射和例外处置记录。最后检查分支保护、pre-commit 模板、共享工作流和开发镜像,避免主流水线已经退出而旧客户端仍执行无人维护的规则。工具可以删除,配置准入责任不能出现空档。
