【Conftest入門】Kubernetesのmanifestファイルをバリデーションする


背景

Kubernetesのmanifestのバリデーションをするため、Conftestを導入した。rego言語によるpolicyを書くことでバリデーションルールを設定できるが、書き方の解説記事が意外と少なかったのでまとめてみる。

Conftestとは

Conftestは、Open Policy Agentで定義されたRego言語を用いて、構造化データに対するバリデーションを行うツールである。バリデーション対象となるデータは、YAMLファイル・JSONファイルなど様々な形式に対応している。Kubernetesのmanifestファイル専用ではないので、その他のフレームワーク・ミドルウェアの設定ファイルに対しても用いることができる。

Rego言語におけるpolicyの文法

Conftestを使うためには、まずRego言語でのpolicyの書き方を知っておく必要がある。
rego言語の文法は公式ドキュメントにまとまっている。全部は触れず、後述のpolicyを理解するために必要な部分のみを紹介する。

policyの基本単位はruleである。重要なのはrule自体は単に変数名とそれにassignされる値を定義しているだけということである。

Rego lets you encapsulate and re-use logic with rules. Rules are just if-then logic statements. Rules can either be “complete” or “partial”.

rule は以下のいずれかの形式になっている。

  • Complete rules
name = value {
  statements
}
  • Partial rules
name[value] {
  statements
}

Complete rulesでは、statementsが全てtrueと評価された場合、 変数 name には値 value が assign される(value が何も指定されなければ trueが値となる)。 Partial rules ではname はSetであり、statementsが全てtrueと評価された場合、値 value が要素として追加される。
statements がひとつでもfalseと評価された場合、何の値もassignされない(ただしdefault句を使うことで、false時にassignする値を定義することもできる)。

例として以下のようなpolicyを書いてみる。

greeting["Hi!"] {
    m := input.message
    m == "Hello!"
}

上のpolicyでは、greeting という名前の rule が定義されている。inputは入力値を指す特殊な変数で、message フィールドの値を取得できる。
:= は新しいローカル変数とその値を定義し、 == は両辺の値が等しければtrue、異なればfalseを返す。 上のpolicyでは m というローカル変数を定義し、その値を評価している。
このstatementsは = を使って書くこともできる。= は左辺の変数が未定義ならば:=、既に定義されていれば == と同じ役割を果たす。

greeting["Hi!"] {
    m = input.message
    m = "Hello!"
}

実際にinputを作成して評価を行うと、以下のような結果になる。 statementsが全てtrueと評価される場合、Set greetingHi! が要素として追加されているのがわかる。

# input
{
    "message": "Hello!"
}

# output
{
    "greeting": [
        "Hi!"
    ]
}

一方、statementsがfalseになる場合、greeting は空となる。

# input
{
    "message": "foo"
}

# output
{
    "greeting": []
}

これらはOpen Policy AgentのPlaygroundで試すことができる。
https://play.openpolicyagent.org/

Conftestでpolicyを書いてみる

Conftestでは、Partial rulesを使って以下のようにpolicyを記載する。

  • 全てのruleの名前は deny, violation, warn のいずれかでなければいけない。 denyviolation では後述する conftest test コマンドの返り値が1に、warnでは0になる。
  • statementsには、そのruleにおいて入力データをrejectする条件を記載する。(=statementsの内容が全てtrueと評価されれば、入力データはrejectされる)
  • statementsが返す値として、reject時のメッセージを記載する。

denyを用いる場合、例えば以下のようになる。

policy/policy.rego
package main

deny["replicas must be specified for Deployment"] {
  input.kind = "Deployment"
  not input.spec.replicas
}

このruleでは、バリデーション対象がk8sのDeploymentオブジェクトの設定ファイルであり、かつreplicas が指定されていない場合、 statementsからreplicas must be specified for Deployment の値が返されバリデーションが失敗する。値が返されるのはrule内のstatementsが全てtrueと評価された場合のみなので、そもそもDeploymentオブジェクトの設定ファイルではなかったり、Deploymentであっても replicas が正しく指定されていたりすれば、何の値も返ってこない。

以下の設定ファイルを検証してみると、実行結果が失敗することがわかる。バリデーション実行は conftest test コマンドで行う。デフォルトでは ./policy ディレクトリ配下のpolicyが使われるが、 -p オプションで場所を指定することもできる。

sample-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: prod
spec:
  # replicasが指定されていない
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: nginx
          resources:
            limits:
              memory: "128Mi"
              cpu: "500m"
          ports:
            - containerPort: 8080
$ conftest test sample-deployment.yaml
FAIL - sample-deployment.yaml - main - replicas must be specified for the deployment

1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

denyとviolationの違い

基本的な書き方は同じだが、violationはstatementsが返す値として構造化データを指定することができる。

violation[{"msg": "replicas must be specified for the deployment"}] {
  input.kind = "Deployment"
  not input.spec.replicas
}

これはGatekeeper向けのpolicyをConftestでのバリデーションにも使えるようにするための機能であるようだ。
https://github.com/open-policy-agent/conftest/pull/243

policyをテストする

policy自体をテストするには、policyと同じディレクトリ (ここでは policy/)に、 *_test.rego という形式のファイルを作る。
以下の例では、replicas が指定されていないDeploymentオブジェクトの設定データを用いて、replicas must be specified for Deploymentがpolicy内のstatementsから返されているかをテストしている。

policy/policy_test.rego
package main

test_deployment_no_replicas {
  deny["replicas must be specified for Deployment"] with input as 
  {
    "apiVersion": "apps/v1",
    "kind": "Deployment",
    "metadata": {
      "name": "myapp",
      "namespace": "prod"
    },
    "spec": {
      "selector": {
        "matchLabels": {
          "app": "myapp"
        }
      },
      "template": {
        "metadata": {}
      },
      "spec": {
        "containers": []
      }
    }
  }
}

conftest verify コマンドでテストを実行できる。

$ conftest verify

1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions, 0 skipped

validなデータをテストするには?

入力データが全てのruleをpassするかをテストするには工夫が必要になる。単にdeny with input as としてしまうと、denyの存在確認しかしていないのでどのような入力値でも通ってしまう。また not deny with input as としても、 rego言語のnot は変数がundefinedかfalseの時のみしか機能しないためエラーとなる。
statementsから返された要素の数を確認したり、特定のエラーメッセージが存在しないことを確認するなどすれば、このようなテストを書くことは可能なようだ。

ただ、個人的にはvalidなデータのテストにあまり意味はないと思っている。データをinvalidとする基準は明確に決められるが、validなデータの基準は「どのruleにも引っかからないもの」としか言いようがなく、validなデータを全パターンテストするのは難しい(例えばk8sアプリケーションを運用しているならば、k8sオブジェクトの種類によってデータ構造は全く違うし、同種のオブジェクトだったとしても異なるアプリケーションでは求められる要件も変わってくる)。
それよりも、invalidな入力データについてのruleを細かな粒度で作り、不正なデータをデプロイ前に弾くdenylist (blacklist)としてConftestを活用する方が、テストの精度が高くなりやすい。