Kubernetesのマニフェストのテストツールを調べてみた

# 概要
KubernetesのマニフェストはYAMLで記載し、gitなどにコミットして管理する事が多いです。
その際に下記のような観点での事前テストを実施する事を考えてみました。

- マニフェストがYAMLとして正しいか
- マニフェストがチームで定めたルールにしたがっているか？
  - 特定のラベルがついているか？またその値が正しいか？

# ツール調査
要件を満たすために使えそうなOSSのツールを調べてみました。

## Copper
https://copper.sh/
Cloud 66という会社が作ったマニフェストのテストツールです。

### 試食

[Getting Started](https://copper.sh/docs/getting-started/)を参考に試しに実行してみました。

:warning: ドキュメントには引数が`-rules`などと書かれていますが、正しくは　`--rules`です

ルールはこのように書きます。
jsonpathで興味のあるリソースをフィルタして、assertを書くと言うようなもののようです。

``` v10only.cop
rule ApiV1Only ensure {
    fetch("$.apiVersion").first == "v1" // we only allow the use of v1 API functions
}
```

```
$ copper check --rules ../rules/v10only.cop --file kube-state-metrics.yaml
check --rules ../rules/v10only.cop --file kube-state-metrics.yaml
Validating part 0
	ApiV1Only - PASS
Validating part 1
	ApiV1Only - FAIL
Validating part 2
	ApiV1Only - FAIL
Validating part 3
	ApiV1Only - PASS
Validating part 4
	ApiV1Only - FAIL
```

1つのファイルに複数のリソースが入っていると上記のように出力されます。リソース名を出して欲しいと思いました。
fetchに引っかからなければその後のassertも実行されないようです。

**リソースを限定してテストする**
DeploymentでapiVersionがこれというような指定もできます。

```
rule always ensure{
  fetch("$[?(@['kind'] == 'Deployment')].spec.template.spec.containers..imagePullPolicy").first == "IfNotPresent"
}
```

上の例は複数あるimagePullPolicyのうち始めのものだけを比較していますが、本当は全ての要素を比較したいです。forEachみたいなものが見当たらず、よい書き方がわかりませんでした。

**特定のラベルが付いているか？**
個数を調べることで存在がわかるようです。

```
rule existLabel ensure {
   fetch("$.metadata.labels['addonmanager.kubernetes.io/mode']")
        .count == 1
    }
```

ただし中身の検証をしようとすると

```
rule labelContents ensure {
    fetch("$.metadata.labels['addonmanager.kubernetes.io/mode']").first == "Reconcile"
    }
```

存在しないリソースの処理の際にランタイムエラーを起こしてしまいました。

```
$ copper check --rules ./rules/nolatest.cop --file kube-state-metrics.yaml
check --rules ./rules/nolatest.cop --file kube-state-metrics.yaml
Validating part 0
	existLabel - PASS
	labelContents - PASS
Validating part 1
	existLabel - PASS
	labelContents - PASS
Validating part 2
	existLabel - PASS
	labelContents - PASS
Validating part 3
	existLabel - PASS
	labelContents - PASS
Validating part 4
Runtime error: cannot call first on an empty array
```

しかし、テスト自体は成功とみなされるようです。エラーハンドリングに改良が必要そうです。

**その他 気になった所**

- && が使えると書いてあるが実際にはsyntax errorする
- 論理演算にかっこが使えない
- andが短絡評価にならないのでガードとして使えない
- fetchを変数に入れられない

ruby製でDSLを使っていると言う事で、実質rubyがフルセットで使えるのかと思ってソースコードをみたところ
[copper/copper.treetop at master · cloud66/copper · GitHub](https://github.com/cloud66/copper/blob/master/lib/copper/grammar/copper.treetop)
普通に独自言語のパーサーから作っているようで、柔軟な処理を書ける雰囲気ではありませんでした。（テスト記述言語としてそう言うことはやらせないというポリシーのようです。）

### まとめ
独自言語でかつまだ完成度が低いので試行錯誤が辛いという印象です。
まだ発展途中のプロダクトのため、PRやIssueを積極的に行うことで使い物になるかもしれません。

## Kubeval
https://github.com/garethr/kubeval

kubernetesのマニフェストのYAMLファイルがYAMLとして正しいかと、KubernetesのAPIに準拠した内容かどうかを調べるツールです。

### 試食
[GitHub - garethr/kubeval: Validate your Kubernetes configuration files, supports multiple Kubernetes versions](https://github.com/garethr/kubeval#installation)

```
wget https://github.com/garethr/kubeval/releases/download/0.6.0/kubeval-darwin-amd64.tar.gz
tar xf kubeval-darwin-amd64.tar.gz
cp kubeval /usr/local/bin
```

指示に従いcurlしました。 go getでもインストール出来ると思います。

**OpenAPIのSchemaの取得先**

```
        --schema-location string      Base URL used to download schemas. Can also be specified with the environment variable KUBEVAL_SCHEMA_LOCATION (default "https://raw.githubusercontent.com/garethr")
```

デフォルトはgithubにあるスキーマを見に行くようです。

[GitHub - garethr/kubernetes-json-schema: A set of JSON schemas for various Kubernetes versions, extracted from the OpenAPI definitions](https://github.com/garethr/kubernetes-json-schema)

しかし、v1.9.3までで更新が止まっています。
スキーマはローカルにおいてfile:// で参照することもできるため、ターゲットとなるKubernetesのバージョンのスキーマをローカルに置いて実行することを考えてみます。

同作者のこれ（[GitHub - garethr/openapi2jsonschema: Convert OpenAPI definitions into JSON schemas for all types in the API](https://github.com/garethr/openapi2jsonschema)）を使うとswagger.jsonをjsonschemaに変換することができるようです。

下記のように実行すると　Kubernetesのリポジトリからschemaを作ることができました。

```
openapi2jsonschema https://raw.githubusercontent.com/kubernetes/kubernetes/master/api/openapi-spec/swagger.json
```

docker imageも用意されていました。 https://hub.docker.com/r/garethr/openapi2jsonschema/

openapi2jsonschemaはいくつかオプションがあります。

- standalone
  - 定義を参照せずに全て展開するオプション
  - 指定するとすごく遅くなりました
- strict
  - 存在しないプロパティがあった場合にエラーとなるようなschemaを出力するオプション
  - 指定すると終わらなくなった・・・

デフォルトはstandaloneのjsonschemaを参照しているようでしたが、standaloneではないjsonschemaでも動作する事を確認しました。

**動作の様子**

上記手法で生成したschemaを利用してコマンドを実行してみました。

```
$ kubeval manifests/* --schema-location file:///Users/inajob/work/_tmp/schema
The document manifests/kube-state-metrics.yaml contains a valid ServiceAccount
The document manifests/kube-state-metrics.yaml contains a valid ClusterRole
The document manifests/kube-state-metrics.yaml contains a valid ClusterRoleBinding
The document manifests/kube-state-metrics.yaml contains a valid Service
The document manifests/kube-state-metrics.yaml contains an invalid Deployment
---> spec.template.spec.containers.0.readinessProbe.httpGet.port: Invalid type. Expected: string, given: integer
The document manifests/kubernetes-dashboard.yaml contains a valid ServiceAccount
The document manifests/kubernetes-dashboard.yaml contains a valid ClusterRoleBinding
The document manifests/kubernetes-dashboard.yaml contains a valid Role
The document manifests/kubernetes-dashboard.yaml contains a valid RoleBinding
The document manifests/kubernetes-dashboard.yaml contains an invalid Service
---> spec.ports.0.targetPort: Invalid type. Expected: string, given: integer
The document manifests/kubernetes-dashboard.yaml contains an invalid Deployment
---> spec.template.spec.containers.0.livenessProbe.httpGet.port: Invalid type. Expected: string, given: integer
The document manifests/prometheus.yaml contains a valid ConfigMap
The document manifests/prometheus.yaml contains a valid ConfigMap
The document manifests/prometheus.yaml contains a valid ServiceAccount
The document manifests/prometheus.yaml contains a valid ClusterRole
The document manifests/prometheus.yaml contains a valid ClusterRoleBinding
The document manifests/prometheus.yaml contains an invalid Service
---> spec.ports.0.targetPort: Invalid type. Expected: string, given: integer
The document manifests/prometheus.yaml contains an invalid Deployment
---> spec.template.spec.containers.0.livenessProbe.httpGet.port: Invalid type. Expected: string, given: integer
---> spec.template.spec.containers.0.readinessProbe.httpGet.port: Invalid type. Expected: string, given: integer
The document _output/prometheus.yaml contains an invalid Ingress
---> spec.rules.0.http.paths.0.backend.servicePort: Invalid type. Expected: string, given: integer
make: *** [kubeval] Error 1
```

それっぽい出力が得られました。ポートの指定が数値になっているが文字列であるべき というエラーが出ていることがわかります。


### まとめ
kubevalを使うとyamlとして正しいかに加えてKubernetesの定義するOpenAPIの仕様を満たしたmanifestかどうかをチェックできます。

検証にはopenapi2jsonschemaで変換したjsonschemaを使用しています。
デフォルトでは[GitHub - garethr/kubernetes-json-schema: A set of JSON schemas for various Kubernetes versions, extracted from the OpenAPI definitions](https://github.com/garethr/kubernetes-json-schema) を見に行くきますが、ここは1.9.3で更新が止まっています。そのためそれ以上のバージョンで検証するためには、自前でjsonschemaを用意する必要があります。

## Kubetest

https://github.com/garethr/kubetest

Kubernetesのマニフェストのユニットテストを実行するためのツールです。

### 試食

**インストール**
goなので簡単です。

```
$ go get github.com/garethr/kubetest
# github.com/garethr/skyhook
../../go-workspace/src/github.com/garethr/skyhook/skyhook.go:30:28: multiple-value skylark.ExecFile() in single-value context
../../go-workspace/src/github.com/garethr/skyhook/skyhook.go:46:24: not enough arguments in call to syntax.Parse
	have (string, []byte)
	want (string, interface {}, syntax.Mode)
../../go-workspace/src/github.com/garethr/skyhook/skyhook.go:62:14: thread.Push undefined (type *skylark.Thread has no field or method Push)
../../go-workspace/src/github.com/garethr/skyhook/skyhook.go:63:14: thread.Pop undefined (type *skylark.Thread has no field or method Pop)
```

とはいきませんでした・・、、skyhookがおかしい？
読む限り関数のインターフェースがあっていないようです。
付属のMakefileをつかってビルドすることに

```
$ make darwin
(略)
vendor/github.com/garethr/skyhook/skyhook.go:30:28: multiple-value skylark.ExecFile() in single-value context
vendor/github.com/garethr/skyhook/skyhook.go:46:24: not enough arguments in call to syntax.Parse
	have (string, []byte)
	want (string, interface {}, syntax.Mode)
vendor/github.com/garethr/skyhook/skyhook.go:62:14: thread.Push undefined (type *skylark.Thread has no field or method Push)
vendor/github.com/garethr/skyhook/skyhook.go:63:14: thread.Pop undefined (type *skylark.Thread has no field or method Pop)
make: *** [darwin] Error 2
```

それでもエラーです。
公式のtravisもエラーになっているからHEADは今ビルドできないようにみえます。
[Travis CI - Test and Deploy Your Code with Confidence](https://travis-ci.org/garethr/kubetest)

Makeだとglide updateするようですが、これだと最新のパッケージがインストールされてしまいます。そして最新のものだと関数のインターフェースが違いコンパイルできません。

glide.lockに記載されたパッケージを利用してビルドする必要があります。
下記手順でインストール出来ました。

```
$ go get github.com/garethr/kubetest
(失敗する)
$ glide install
(略)
$ go get github.com/garethr/kubetest
(うまくいく)
```

コンパイル済みのバイナリもあります。素直にこちらを使うのもよいでしょう。

[Releases · garethr/kubetest · GitHub](https://github.com/garethr/kubetest/releases)

**実行してみる**

`./tests`ディレクトリにテストを入れると自動で読み込みます。(指定することもできます)

ルールはこのように記述します。

```latest.sky
def test_for_latest_image():
    if spec["kind"] == "ReplicationController":
        for container in spec["spec"]["template"]["spec"]["containers"]:
            tag = container["image"].split(":")[-1]
            assert_not_equal(tag, "latest", "should not use latest images")

test_for_latest_image()
```

```
$ kubetest _output/kube-state-metrics.yaml
FATA .latest.sky.swp:1:10: got float literal, want newline
```

拡張子関係なくtestsのファイルを読むらしい・・vimの一時ファイルを動かそうとしている・・消してやり直します。

```
$ kubetest _output/kube-state-metrics.yaml
WARN _output/kube-state-metrics.yaml "map[k8s-app:"kube-state-metrics"]" does not contain "addonmanager.kubernetes.io/mode"
```

それっぽく動いています。
しかしどのmanifestを処理しているか教えてくれない・・

**どのリソースを処理しているかわかるようにする**

1つのファイルに複数のリソースが定義してあるときは何がエラーなのかわかりにくいので、出力するようにします。

```
def test_for_latest_image():
    print(spec["kind"] + " " + spec["metadata"]["name"])
    if spec["kind"] == "ReplicationController":
        for container in spec["spec"]["template"]["spec"]["containers"]:
            tag = container["image"].split(":")[-1]
            assert_not_equal(tag, "latest", "should not use latest images")

test_for_latest_image()
```

**崩れたYAMLをテストしようとする**

```
$ kubetest manifest/kube-state-metrics.yaml && echo "ok"
WARN The document manifest/kube-state-metrics.yaml does not contain any content
ClusterRole kube-state-metrics
ClusterRoleBinding kube-state-metrics
Service kube-state-metrics
Deployment kube-state-metrics
ok
```

妙なWARNが出るがコマンドはOKを返します。YAMLがvalidかはこのツールだけではわからないようです。

### テスト定義のDSLについて

- Pythonのサブセットであるskylarkという言語で記述する。（https://github.com/google/skylark）
- Pythonとにているがフルセットではないので戸惑うことがある。マニュアルはしっかりしている。[skylark/spec.md at master · google/skylark · GitHub](https://github.com/google/skylark/blob/master/doc/spec.md#dictionaries)
- テスト用の関数を定義した後,最後に実行するのが少しダサい。 Issueもできているのでそのうちよくなりそう [Autodetect functions starting with test_ · Issue #2 · garethr/kubetest · GitHub](https://github.com/garethr/kubetest/issues/2)

### まとめ

いくつか気になるところがあるが、manifestが特定のルールにしたがっているかを柔軟に検証することができる良いツールであると感じました。
HEADがビルドできないのが気になります。

# 全体のまとめ

- Copper
- kubeval
- kubetest

をそれぞれ調査しました。どれも完璧というものではないですが、事前テストをすることでmanifestの品質を高めることができます。

# 参考
- kubetest, kubeval の作者のプレゼン資料
  - https://speakerdeck.com/garethr/developer-tooling-for-kubernetes-configurations