Helmチャートのスナップショットテストのすゝめ

この記事は[Kubernetes Advent Calendar 2023](https://qiita.com/advent-calendar/2023/kubernetes)の10日目の記事です。

# はじめに

Helmチャートのテストと継続的な一貫性の確保は、Helmチャートを作成した経験のある方にとっては永遠についてくるタスクです。新機能やKubernetesのAPIへの変化に対応するためにHelmチャートに新しいプロパティなどを追加する場合など、バージョンアップしてもユーザーが保持するValuesに対して一貫性を維持する必要があります。

Helmチャートを直接作成していない場合でも、他の誰かが作成したHelmチャートを自身のクラスターにインストールするために作成したvaluesファイルがある限り、チャートがアップデートされていく過程で問題なく機能するか、またDeprecateされたプロパティがないか、期待される生成結果に変更がないかを確認する必要があります。

Helmの実行に必要なのは基本的に対象のチャートとValuesだけで、入力されたValuesに対して、マニフェストの全量が出力されます。
Valuesファイルのパターンに応じて出力が異なる部分と、Valuesの組み合わせによりその他の部分に影響がないことを確認する必要があるでしょう。

となると、思いつくのはWebのスナップショットテストです。
`helm template YOUR_CHART -f VALUES.yaml`の実行結果をスナップショットとしてCIで継続的に取得し、差分を確認していけたらいいなと思いました。

そのためスナップショットが出来るツールをいくつか探したのですが、手軽に実行できるものを見つけることができず、Helmに特化したシンプルなスナップショットテストツールを作成しました。
Helmにはプラグイン機構があり、Helmプラグインとして作成したため、最後にそのナレッジもまとめたいと思います。

# 既存ツール

Helmチャートのスナップショットテストを行えるツールはすでにいくつかあります。[^1]

- `helm-unittest`: Helmのユニットテストツールです。一般的な単体テストのassertによるテストに加えてスナップショットテストの機能もあります。

https://github.com/helm-unittest/helm-unittest#snapshot-testing
helm-unittestでスナップショットテストを行うには下記のようなテストスペックを用意します。

```yaml
templates:
  - templates/deployment.yaml
tests:
  - it: manifest should match snapshot
    asserts:
      - matchSnapshot: {}
```

- `terratest`: IaCの包括的なテストツールで、Goでテストコードを記載します。Helmの実行結果を扱うパッケージがあります。ただスナップショットを取得するコードは自分で書かないといけなそうです。

https://github.com/gruntwork-io/terratest

https://blog.gruntwork.io/automated-testing-for-kubernetes-and-helm-charts-using-terratest-a4ddc4e67344?gi=3a713d558bfa

調べた限りアクティブなプロジェクトは上記の2つかなと思います。

ただこれらはスナップショットテストに特化したツールではなく、単に`helm template YOUR_CHART -f VALUES.yaml`をやりたいだけの私としては、以下の点で求めていた機能が不足していました。

- *素のValuesファイルが使える*: `helm template YOUR_CHART -f VALUES.yaml`をやりたいだけなので、あくまでも素のValuesファイルを用意するだけで実行できる手軽さが欲しいです。ツール固有のテストスペックファイルを用意したくありません。

- *生成結果全てを扱う*: 確認したいのはテンプレートの一部ではなく、生成結果全てです。
例えば`helm-unittest`はファイルベースの考え方であるため、テストスペックファイルにどのファイルのスナップショットを撮るか指定する必要があり、以下のパターンではうまく機能しませんでした。

よくあるtemplateの構成としては次のような構成があると思います。
例1: ingressのオプションで、`kind: Ingress`のマニフェストファイルを用意し、ファイル全体をifブロックで囲う。最近だとIngressリソースに加えてGatewayリソースのオプションも追加が必要な場合もあります。
例2: httpsのオプションで、cert-managerを利用するか、Helmの自己証明書生成機能で生成された証明書を使用するか選べる。cert-managerの`kind: Certificate`のマニフェストファイルを用意し、ファイル全体がifブロックで囲う。

`helm-unittest`のスナップショットでは、ファイルに対する生成結果が存在しない場合のテストを行うことができません。


- *Helm Functionへの対応*: Helmにはランダム値や自己証明書などを動的を生成する機能があります。スナップショットテストにおいては動的に生成された項目のみ対象から除外したいですが、これらのツールは動的な値を扱うことができなかったり、扱う場合にコードを書く必要があります。
https://helm.sh/docs/chart_template_guide/function_list/#randalphanum-randalpha-randnumeric-and-randascii

- *他のチャートに対するValuesファイルの確認*: 自分で作成したHelmチャートだけでなく、他の人のHelmチャートに対して自分のValuesファイルで生成された結果を保持したいです。既存のツールはあくまで自分で作成したチャートを対象としており、できても手軽には取得できません。

# helm-chartsnap

上記のような点からHelmに特化したシンプルなスナップショットテストツールを作成しました。
Helmにはプラグイン機構があるので、Helmプラグインとして作成しています。

https://github.com/jlandowner/helm-chartsnap

実行イメージは下記のとおりです。

![Screenshot](https://github.com/jlandowner/helm-chartsnap/raw/main/docs/screenshot.png)

# インストール

以下のコマンドでHelmプラグインとしてインストールできます。

```sh
helm plugin install https://github.com/jlandowner/helm-chartsnap
```

# 主な機能

### 手軽さ

まず、デフォルトのValuesファイルに対してスナップショットをとる場合はチャートを指定するだけです。

```sh
# デフォルトのValuesを使用する場合
helm chartsnap -c YOUR_CHART
```

もちろん特定のValuesファイルに対して実行することもできます。

```sh
# 特定のvaluesファイルを使用する場合
helm chartsnap -c YOUR_CHART -f https-enabled-values.yaml
```

テストケースはあくまで期待するパターンの`values.yaml`を複数用意するだけです。
次の通り、まとめてスナップショットも撮れます。

```sh
# 複数のvaluesファイルを配置したディレクトリに対してまとめてスナップショットを取得する
helm chartsnap -c YOUR_CHART -f ./tests/
```

内部では単に`helm template`を実行しているだけなので、`helm template`コマンドの任意のオプションが使えます。
(任意のオプションを渡せるようにパッケージを読み込むのではなく、helmのバイナリを実行するようにしました。)

```sh
# helm testをスキップする
helm chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILE -- --skip-tests

# 特定のリソースだけ出力する
chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILE -- --show-only SOMETHING
```

Jestと同様で、スナップショットファイルがない場合はスナップショットを作成し、スナップショットがあれば差分を出力します。また差分を更新する場合は`-u`オプションをつけて実行するだけです。

```sh
# スナップショットの更新
helm chartsnap -c YOUR_CHART -u
```

### 外部のチャートのスナップショットの取得

単に`helm template`を実行しているだけなので、他の人のチャートのスナップショットも取得可能です。

例: [ingress-nginx](https://kubernetes.github.io/ingress-nginx/)のHelmチャートのスナップショットを取得

```sh
# helm templateコマンドの`--repoオプション`を使用することで、事前にリポジトリの追加をすることなく実行できる
helm chartsnap -c ingress-nginx -f YOUR_VALUES_FILE -- --repo https://kubernetes.github.io/ingress-nginx --namespace kube-system --version 4.8.3
```

例： Hubbleを有効化した[cilium](https://cilium.io)のHelmチャートのスナップショットを取得

```sh
helm chartsnap -c cilium -- --repo https://helm.cilium.io --namespace kube-system --set hubble.relay.enabled=true --set hubble.ui.enabled=true
```

例: OCIレジストリに格納されたチャートのスナップショットを取得する

```sh
helm chartsnap -c oci://ghcr.io/nginxinc/charts/nginx-gateway-fabric -n nginx-gateway
```

その他の例はこちらを参照してください。
https://github.com/jlandowner/helm-chartsnap/tree/main/example/remote


## Helmに特化した機能

### 差分箇所の詳細表示

Helm特化のツールであるため、単にスナップショットの差分を表示するのではなく、複数のYAMLで構成された出力結果を読み込んで、どのリソースの何行目の差分かを表示します。

![Screenshot](https://github.com/jlandowner/helm-chartsnap/raw/main/docs/screenshot.png)

### Helm Functionによる動的な出力の取り扱い
Helm Functionを使用して動的な値となる箇所は、values.yamlファイル内にJSONPathで指定することで、実行毎に差分として出力されないようにできます。

```yaml
# chartsnapの動作変更用のプロパティをValuesファイルに追加する
# この場合、生成された結果のうち`auth-env`という名前のSecretの`.data.SESSI0N_KEY`という項目が固定値に置換されます。
testSpec:
  dynamicFields:
    - apiVersion: v1
      kind: Secret
      name: auth-env
      jsonPath:
        - /data/SESSI0N_KEY

# 他は普通のValuesでOK
# ...
```

これにより対象箇所は固定値に置換され、スナップショット上で差分として現れてこないようになります。

# GitHub Actionも用意しました

継続的にchartsnapを使用してスナップショットテストを実行するためのGitHubアクションも用意しました。これにより継続的にスナップショットを取得し、GitHub上でも差分の確認ができます。

https://github.com/marketplace/actions/helm-chartsnap-action

GitHub ActionsのWorkflowファイルの例です。どのチャートにどのValuesファイルを取得して実行するかを指定するだけです🙌

```yaml
# GitHub Actionでingress-nginxのスナップショットを取得し、差分があればプルリクエストを作成して通知する例
jobs:
  chartsnap-ingress-nginx:
    runs-on: ubuntu-latest
    name: Do snapshot ingress-nginx and create PR if snapshot changed
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Chart Snapshot for ingress-nginx
        uses: jlandowner/helm-chartsnap-action@v1
        with:
          repo:   https://kubernetes.github.io/ingress-nginx # Helmリポジトリ
          chart:  ingress-nginx # チャート名
          values: charts/remote/ingress-nginx.values.yaml # Valuesファイルパス
          update_snapshot: true # スナップショットの差分があった場合にプルリクエストを作成する
```

このアクションを定期的に実行することで、以下を実現できます。

#### チャートの新バージョンがリリースされたことが検知できる。
スナップショットには実行したチャートやアプリケーションのバージョンが含まれるため、dependabotのように、チャートが更新されるとバージョン番号の差分が出ます。

以下のようなプルリクエストが作成されます。

![Screenshot](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279529/0a0c4618-b1c8-aafb-e513-664b6e0ccf5f.png)


#### GitHub上で差分を可視化できる。

GitHub上でプルリクエストとして差分を確認できます。
![Screenshot](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279529/9c76de9d-79de-2aab-f214-ccd4d464b910.png)

差分が問題ない時はプルリクエストをマージするだけです!


# Helmプラグインについて

今回Helmのプラグインを初めて作りました。Helmプラグインの歴史は長く、`helm template`コマンドも元々プラグインとして実装されていたようです。少しわかりにくい点もあったので簡単にまとめておきたいと思います。

## Helmプラグインの仕様

Helmプラグインは何かHelmコマンドにコードを組み込む機能ではなく、プラグインとして定義した任意のコマンドを実行できる機能です。

kubectlのkrewのように中央集権的なパッケージリポジトリはなく、Helmプラグインの仕様を定義した`plugin.yaml`という名のファイルが配置されたGitリポジトリを指定するだけでプラグインとしてインストールが可能です。

Helmの公式ドキュメント上に[Related Projects and Documentation](https://helm.sh/docs/community/related/)というページがあり、プラグインのリストがあります。Helmプラグインはプラグインリポジトリはないのでプラグインの全量がリストされているわけではなく、どういう基準でリストされているのかわかりませんでした。ので、とりあえずchartsnapnの追加をプルリクエストしてみたところすぐにマージしてくれましたので、有名かどうかに関わらず問題なければマージしてくれるのかと思いました。

https://github.com/helm/helm-www/pull/1524

`plugin.yaml`の仕様は下記のとおりです。

https://helm.sh/docs/topics/plugins/#building-plugins

いくつかのHelmプラグインリポジトリを参考に、chartsnapは以下のようなファイルにしました。

```yaml
# helm plugin config file
# Ref: https://helm.sh/docs/topics/plugins/
name: chartsnap
version: 0.0.8
usage: Snapshot testing for Helm charts
description: Snapshot testing for Helm charts
command: "$HELM_PLUGIN_DIR/bin/chartsnap"
ignoreFlags: false
hooks:
  install: "cd $HELM_PLUGIN_DIR; scripts/install_plugin.sh"
  update: "cd $HELM_PLUGIN_DIR; scripts/install_plugin.sh"
```

helmのプラグインドキュメントを見ると他にもいくつかプロパティがありますが、確認した既存プラグインは概ね上記のようになってました。

インストールスクリプトも大体同じで、GitHubリリースから最新のtar.gzを取得して解凍するスクリプトを実行するようになっており、install, updateに関わらずGitHubの最新リリースを見に行くようになってました。そのためchartsnapも他のリポジトリから流用させていただきましたが、GitHubの最新リリースではなく、`plugin.yaml`内の`version`のリリースを落とすように修正しました。

https://github.com/jlandowner/helm-chartsnap/blob/main/scripts/install_plugin.sh

最後にコマンドラインオプションについてです。helmにはいくつかグローバルオプションがありますが、helmのプラグインのスクリプトに渡るコマンドラインオプションのうち、helmのグローバルオプションと同名のオプションはプラグイン側のコマンドラインオプションとして渡ってきません。

具体的には、例えば`--debug`オプションをツールに実装したとして、単体で実行する時と、Helmプラグインとして実行するときで挙動が変わります。

今回のツールは次のようにGoでcobraパッケージを使用したCLIとして作成しています。

```go
var (
    o = &option{}
)

type option struct {
	...
	// Below properties are the same as helm global options
	// They are passed to the plugin as environment variables
	NamespaceFlag string
	DebugFlag     bool
}
```

Cobraにフラグとして`--debug`や`--namespace`を実装しています。これらはHelmのグローバルオプションと被っていますが、単体での実行時には必要なオプションです。

```sh
func main() {
    ...
    rootCmd.PersistentFlags().BoolVar(&o.DebugFlag, "debug", false, "debug mode")
    rootCmd.PersistentFlags().StringVar(&o.NamespaceFlag, "namespace", "default", "namespace. this flag is passed to 'helm template RELEASE_NAME CHART --values VALUES --namespace NAMESPACE' as 'NAMESPACE'")
    ...
}
```

これらのオプションを指定した場合、単体で実行する時と、Helmプラグインとして実行するときで挙動が変わります。

```sh
# 単体で実行する場合
go run main.go --debug # o.DebugFlagはtrueになる

# Helmプラグインとして実行する場合
helm chartsnap --debug # o.DebugFlagはfalseになる
```

Helmプラグインとして実行する場合、Helmのグローバルオプションは一度Helmコマンド側で消費され、プラグインには環境変数として渡ってきます。

そのため、開発やテストで単体でも実行可能とするためには、コマンドラインオプション＋環境変数も見に行くように実装が必要です。

今回はフラグ用の変数名は`XXXFlag`とし、オプションの値を参照する場合は以下のように`XXX()`関数を用意して呼び出すようにしました。

```go
func (o *option) Debug() bool {
	helmDebug, err := strconv.ParseBool(os.Getenv("HELM_DEBUG"))
	if err == nil {
		return helmDebug
	}
	return o.DebugFlag
}

func (o *option) Namespace() string {
	helmNamespace := os.Getenv("HELM_NAMESPACE")
	if helmNamespace != "" {
		return helmNamespace
	}
	return o.NamespaceFlag
}
```

その他、渡ってくる環境変数の一覧は下記のとおりです。
https://helm.sh/docs/topics/plugins/#environment-variables

# 最後に

HelmのCIの一助となれば幸いです。
手軽に使えるので、ぜひ一度使ってみてフィードバックいただけると嬉しいです。


[^1]: 参考記事
https://speakerdeck.com/daikurosawa/unit-testing-helm-chart
https://zenn.dev/lhan/scraps/1bc3e899a587c7