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

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

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

# Rego言語におけるpolicyの文法
Conftestを使うためには、まずRego言語でのpolicyの書き方を知っておく必要がある。
rego言語の文法は[公式ドキュメント](https://www.openpolicyagent.org/docs/latest/policy-language/)にまとまっている。全部は触れず、後述のpolicyを理解するために必要な部分のみを紹介する。

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

https://www.openpolicyagent.org/docs/latest/#rules

> 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

```rego
name = value {
  statements
}
```

- Partial rules

```rego
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を書いてみる。

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

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

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

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

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

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

```rego: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` オプションで場所を指定することもできる。

```yaml: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
``` 

```console
$ 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が返す値として構造化データを指定することができる。

```rego
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から返されているかをテストしている。

```rego: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` コマンドでテストを実行できる。

```console
$ 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から返された要素の数を確認したり、特定のエラーメッセージが存在しないことを確認するなどすれば、このようなテストを書くことは可能なようだ。

https://stackoverflow.com/questions/60083793/rego-testing-how-to-test-not-deny

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