FluxCDのHelmReleaseでinstall後のリソースの差分を出す

目的

自宅 k8s の manifest を管理するリポジトリであるwalnuts1018/infraでは、renovate を用いて、HelmRelease の自動更新を行っています。 FluxCD の HelmRelease リソースには、Chart の Version を指定する項目があるのでそこが更新されていきます。

しかし、リポジトリに Commit されているのはあくまでも HelmRelease の Manifest であり、helm install をするのは Server-Side の helm-controller です。 そのため、Helm Chart 更新の Pull Request を出されても、結局何が変わるのか一目ではわかりません。

そこで、PR の base と head でhelm installした後に生成されるリソースの差分を出力し、PR にコメントされるような GitHub Action を作成しました。

完成形

さっそく完成形を置いてしまいます。

https://github.com/walnuts1018/infra/pull/307#issuecomment-2039935834

このように、PR にコメントが投稿され、Helm によって生成されるリソースの差分が表示されています。

方針

  • base と head、それぞれで kustomize build を行い、一つの Manifest にまとめる
  • その Manifest から、HelmRepository と HelmRelease リソースを抽出する
  • HelmRepository リソースを yq でごにょごにょして、helm repo addを行う
  • HelmRelease リソースも yq でごにょごにょして、helm templateを行う
  • 一番最初の Manifest ファイルに追記する
  • 最後に diff を生成し、API 経由でコメントを投稿する

kustomize build

actions/checkout を使って、PR の base と head のリポジトリをチェックアウトし、azure/k8s-bake を使って kustomize build を行います。

- uses: actions/checkout@v4
  with:
    ref: ${{ github.event.pull_request.base.ref }}

- uses: azure/k8s-bake@v3
  with:
    renderEngine: "kustomize"
    kustomizationPath: "./k8s/clusters/kmc/"
    kubectl-version: "latest"
  id: base_bake

- run: "mv ${{ steps.base_bake.outputs.manifestsBundle }} /tmp/manifests-base.yaml"

- uses: actions/checkout@v4

- uses: azure/k8s-bake@v3
  id: base_changed
  with:
    renderEngine: "kustomize"
    kustomizationPath: "./k8s/clusters/kmc/"
    kubectl-version: "latest"

- run: "mv ${{ steps.base_changed.outputs.manifestsBundle }} /tmp/manifests-head.yaml"

この部分はもともと存在していたのでそのまま使うだけです

Dependency のインストール

yq と jq と helm をいれます

- name: install dependencies
  run: |
    # Install jq
    sudo wget https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 -O /usr/bin/jq
    sudo chmod +x /usr/bin/jq

    # Install yq
    sudo wget https://github.com/mikefarah/yq/releases/download/v4.43.1/yq_linux_amd64 -O /usr/bin/yq
    sudo chmod +x /usr/bin/yq

    # install helm
    curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash
    ```

HelmRepository と HelmRelease の抽出

/tmp/manifests-base.yaml/tmp/manifests-head.yaml から、HelmRepository と HelmRelease のリソースを抽出します。

- name: HelmReleaseとHelmRepoを抽出
  run: |
    cat /tmp/manifests-head.yaml |yq -j '. | select(.kind == "HelmRelease") | sort_keys(.metadata.name .metadata.namespace)' > /tmp/helmreleases-head.json
    cat /tmp/manifests-head.yaml |yq '. | select(.kind == "HelmRepository") | sort_keys(.metadata.name .metadata.namespace)' > /tmp/helmrepository-head.yaml

    cat /tmp/manifests-base.yaml |yq -j '. | select(.kind == "HelmRelease") | sort_keys(.metadata.name .metadata.namespace)' > /tmp/helmreleases-base.json
    cat /tmp/manifests-base.yaml |yq '. | select(.kind == "HelmRepository") | sort_keys(.metadata.name .metadata.namespace)' > /tmp/helmrepository-base.yaml

helm repo の追加

/tmp/helmrepository-[head|base].yaml から、helm の repository 情報を取得し、helm repo addを行います。

- name: helm repo add
  run: |
    # helm repo add head-<.metadata.name-.metadata.namespace> <.spec.url>
    cat /tmp/helmrepository-head.yaml | yq -r '"head-" + .metadata.name + "-" + .metadata.namespace + " " + .spec.url' | while read line; do
      helm repo add $line
    done

    # helm repo add base-<.metadata.name-.metadata.namespace> <.spec.url>
    cat /tmp/helmrepository-base.yaml | yq -r '"base-" + .metadata.name + "-" + .metadata.namespace + " " + .spec.url' | while read line; do
      helm repo add $line
    done
    ```

[head|base]-<.metadata.name-.metadata.namespace> という名前で helm repository が追加されました。 yq の中で実行コマンド文字列を生成しているので、Parse 処理が一回で済みます。

helm template

/tmp/helmreleases-[head|base].json/tmp/helmrepository-[head|base].yaml をもとに、helm templateコマンドを使って、リソースを生成します。

helm install --dry-run は、k8s cluster にアクセスできる必要がありますが、helm template は helm 単体で実行できるので、こちらを使います。

- name: helm template
  run: |
    length=$(cat /tmp/helmreleases-head.json | jq -s length)
    for i in $( seq 0 $(($length - 1)) ); do
      cat /tmp/helmreleases-head.json | jq -rs ".[$i] | .spec.values" | tee values.json >> /dev/null
      command=$(cat /tmp/helmreleases-head.json | jq -rs "\"helm template \" + .[$i].metadata.name + \" --namespace \" + .[$i].metadata.namespace + \" head-\" + .[$i].spec.chart.spec.sourceRef.name + \"-\" + .[$i].metadata.namespace + \"/\" + .[$i].spec.chart.spec.chart + \" --version \" + .[$i].spec.chart.spec.version + \" -f values.json\"")
      eval $command | yq  "sort_keys(..)" >> /tmp/manifests-head.yaml
    done

    length=$(cat /tmp/helmreleases-base.json | jq -s length)
    for i in $( seq 0 $(($length - 1)) ); do
      cat /tmp/helmreleases-base.json | jq -rs ".[$i] | .spec.values" | tee values.json >> /dev/null
      command=$(cat /tmp/helmreleases-base.json | jq -rs "\"helm template \" + .[$i].metadata.name + \" --namespace \" + .[$i].metadata.namespace + \" base-\" + .[$i].spec.chart.spec.sourceRef.name + \"-\" + .[$i].metadata.namespace + \"/\" + .[$i].spec.chart.spec.chart + \" --version \" + .[$i].spec.chart.spec.version + \" -f values.json\"")
      eval $command | yq  "sort_keys(..)" >> /tmp/manifests-base.yaml
    done

helmreleases-[head|base].json に、それぞれの HelmRelease のリソースが jsonl として入っているので、jq -sで配列に変換、lengthで長さを取得し、for ループで一つずつ処理していきます。

対象の HelmRelease のspec.valuesを取得し、values.json として出しておきます。

次に、helm template コマンドを組み立てていきます。これも jq の中で文字列を生成しているので、一回で済みます。 helm template <HelmReleaseのname> --namespace <HelmReleaseのnamespace> <[head|base]-HelmRelease中で指定されているHelmReporisitoryの名前>-<HelmReleaseのnamespace(=HelmRepositoryのnamespace)>/<HelmRelease中で指定されているChartの名前> --version <HelmRelease中で指定されているChartのバージョン> -f values.json という文字列が最終的に出来上がります。

そして最後にこれを実行し、出力されたリソースを/tmp/manifests-[head|base].yamlに追記していきます。デフォルトで1行目に---が入るので、そのまま追記できます。 ただしここで、sort_keys(..)を使って、リソースの順番を揃えています。完全ではありませんが、ほとんどの項目が head と base で同じ順番になるので余計な差分が出にくくなります。

diff の生成

ただ diff コマンドを実行するだけです

- name: Build markdown comment with manifest diff
  run: |
    echo "## Manifest diff
    <details>
    <summary>Click to expand</summary>

    \`\`\`diff
    $(diff -u manifests-base.yaml manifests-head.yaml)
    \`\`\`

    </details>" | tee /tmp/comment.md
    ```

PR にコメントを投稿

これが少し難しいですが、「Pull request レビュー コメント用 REST API」を使うのではなく、「issue コメント用の REST API」を使います。 「Pull request レビュー コメント用 REST API」は、PR のコミットに対するコメントが付くので、少し探しにくくなってしまいます。

「issue コメント用の REST API」は、issue だけでなく PR にも使うことができ、これが一番皆さんの使う「PR のコメント」となります。

- name: Comment manifest diff to GitHub PR
  run: |
    cat /tmp/comment.md | jq -Rs '{ "body": . }' | curl --fail \
      -X POST -H 'Accept: application/vnd.github.v3+json' \
      --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \
      https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.number }}/comments \
      -d @-

まとめ

もっと複雑になるかと思ったのですが、意外と単純に行けました。 あと、実行時間ももっとかかるかと思ったのですが、1 分ちょいで終わります。

これで少しは PR の中身をちゃんと確認する気が起きそうです。(今までは脳死で Merge していました) renovate の PR だけではなく自分で更新する際にも意図しない変更がないかどうか確認出来てよさそうです。

とりあえず自宅 infra のリポジトリと、KMC のリポジトリに導入してみました。しばらく様子をみて改善していきたいと思います。

また、今後は「kustomize の resources に include していない」「Chart の values.yaml に存在しない項目を指定している」などについてもテストを用意していきたいな~と考えています。