nariのエンジニアリング備忘録

SRE/Devops/AWS/自動化/IaC/Terraform/Go/DDD など

Terraformのレビューを自動化するために、Conftestを導入してGithub ActionsでCIまで設定してみる

f:id:st5818129:20211225154739p:plain

English Version:

dev.to

はじめに

メリークリスマス。eureka, inc. でSREをやっているnari/wapperです。

こちらは Calendar for terraform | Advent Calendar 2021 - Qiita として書きたかったものを成仏させるために書いています。(登録するのを忘れていました....)

弊社SRE Teamでは、自分たち自身がボトルネックにならないための活動方針推して、来年の大きなキーワードの一つにマイクロサービスレディを掲げる予定です。(マイクロサービス化していくわけではない)

詳しくは以下のSRE Team責任者の marnie0301 (@marnie0301) | Twitterの発表をご覧ください

speakerdeck.com

それに伴い、Production Readiness checkとともに、移譲容易性/Self Serviceを促進するのに重要なピースとなるPolicy as Codeの運用をConftestで試し始めてみたので、そのお話を備忘録的に書いていこうかなと思います。

今回はTerraformのresource tagのルール(AWS reousrce)をRegoでポリシーを書きつつそのポリシー自体のテストまで書いて、その自動実行CIをGithub Actionsで整備する流れで紹介していきます。 (Conftest(Rego)の記述の話は多いけど、CI周りの設定まわりがあまり落ちていなく、何点かハマりポイントがあったので、これから試す方がはまらないように備忘録を残しておきたいと言うのがこの記事を書いている一番大きなモチベーションです)

対象読者

  • Policy as Code気になってはいたけど始めるきっかけが欲しかった人
  • ConftestをGithub ActionsのCIに落とし込む具体例が知りたい人

OPA/Rego/Conftestとは

また今年は、25日間ひたすらOPA/Regoについて書く 狂気的な 素敵なアドベントカレンダーがあったりしてとても参考になるので是非そちらを一通り読むことをお勧めします。

adventar.org

Regoでポリシールールを記述して、ルール自体のテストも記述しながらCIへ組み込んでいくまで

ここからのサンプルコードはこちらにすべて置いております:

github.com

今回はterraformで作成するリソースが最低限のtag(name/owner/description)を持っていて、かつdata store resourceだった場合にdataという名前のtagにdataの重要性(high/middle/low)が設定されていることを確認するポリシールールを書いてみたいと思います。terraform planの結果をjsonに変換したものを入力値として、それを今から書くRegoのポリシールールでテストできるように、かつそのポリシールール自体もテストできるようにしていきます。(説明していくサンプルはAWSの使用を想定しています)

Conftest(OPA/Rego)のセットアップ

  • Mac OS ユーザーであればbrewで簡単にいれることができます。その他のOSのユーザーもそれぞれのREADMEを参考に導入をすることができます。
brew install conftest

# opaもいれておく
brew install opa
  • EditorのPluginに関しては、IntelliJならopa pluginVSCodeなら opa plugin が使えます
    • 他のEditorでもあると思うので探して入れてみてください

前提知識: Terraform plan 結果の構造

f:id:st5818129:20211228204336p:plain

(https://speakerdeck.com/ryokbt/terraformfalserebiyuwoconftestdezi-dong-hua-suru?slide=20 参考)

このような構造をしており、このデータをinputとしてその内容をチェックするようなロジックをRegoで書いていきます。

ConftestでTerrafom resource tag ルールを書いてみる

  • まず、main.regoでエントリーポイントを記述していきます。
# policy/main.rego


package main //❶

import data.tags_validation //❷

#####################################
# Policy as Code Rules
#####################################
deny_tags_contain_minimum_set[msg] {
   # Only target resources that have been changed/added.
    changeset := resources_not_no_op_action[_] //❸

    not tags_validation.contain_proper_keys(changeset.change.after.tags)

    msg := sprintf("Invalid tags (missing minimum required tags: name,owner,description) for the following resource: %v", [changeset.address]) //❹
}

deny_data_store_data_tag_is_proper[msg] {
   # Only target resources that have been changed/added.
    changeset := resources_not_no_op_action[_]

   # Only when resource_type is a data source/store type that can contain sensitive information
    tags_validation.is_data_tag_required_target_resource(changeset.type)

    tags_validation.not_has_proper_data_tag(changeset.change.after.tags.data)

    msg = sprintf("data tag needs to be set to one of [low,high,middle] resource: %v", [changeset.address])
}

#####################################
# Utils
#####################################

resources_not_no_op_action= {resource | resource := input.resource_changes[_]; resource.change.actions[_] != "no-op"} //❺

resources_with_type(resources, type) = all {
    all := [item | item := resources[_]; item.type == type]
}

❶ ポリシーには必ず package 句が必要です。名前空間を分割するために任意のパッケージ名をつけることができます。

❷ import句で別の名前空間をimportしてきてそちらのリソースを使うことができます。tags_validationという名前空間の方でリソース独自のロジックを書いてそれをmain側で使用していきます

❸ ここで、actionが"no-op"でないリソースにリソースを絞り、それらのリソースをイテレーション([_]で記述する)で回してポリシー違反のリソースがないチェックします。これで新しく変更/追加があったリソースのみを対象とすることができます。また、この操作は基本どのポリシールールでも使用するので、共通の関数にして切り出しています。

❹ 同一ルール内では複数の評価式を記述でき、それらはANDで評価されるので、全てがtrueとなった場合のみdeny/violationと判定されてこのそれぞれのポリシールール用のエラーメッセージ配列にエラーメッセージが登録されテスト実行時に出力されます。

❺ RegoではPythonでおなじみの内包表記が使えるようになっています。文法としては [<結果として返す値> | <ルール>] となっていて、ルールに当てはまるリソースだけを返します。

  • 次にmainで使っていた、tags_validationロジックを記述していきます
# policy/tags.rego

package tags_validation

minimum_tags = {"name", "owner", "description"}

contain_proper_keys(tags) {
   # Subtract the key list of the given tags from the minimum tag list, and if there is no more left, you have the minimum tag.
    keys := {key | tags[key]}
    leftover := minimum_tags - keys
    leftover == set()
}

not_has_proper_data_tag(value) { // ❶
    value != "low"  
    value != "middle"
    value != "high"
}

is_data_tag_required_target_resource(type) { // ❷
    type == "aws_dynamodb_table"
}

is_data_tag_required_target_resource(type) {
    type == "aws_s3_bucket"
}

❶ 同一ルール内での評価式はANDになるので"low"でも"middle"でも"high"でもない場合にtrueが返ります

❷ 同一ルール内での評価式はANDで評価されるので、ORの論理を記述したい場合はis_data_tag_required_target_resourceのように同名の複数の処理を書くことで実現が可能です。

ConftestでRegoで書いたルール自体のテストを書いて、実行してみる

  • まずmain.regoのテストを書いていきます。こちらはGoなどと同様に${test_target_file_name}_test.regoという風な命名をします。
# policy/main_test.rego

package main

#####################################
# Tests of Policy as Code Rules
#####################################

...

test_tags_contain_minimum_set {
    plan := `                    // ❶
      resource_changes:
        - name: case normal
          address: module.one
          type: aws_security_group_rule
          change:
            actions:
              - create
            after:
              tags:
                name: hoge
                owner: piyo
                description: for test
    `

    input := yaml.unmarshal(plan)
    deny_tags_contain_minimum_set == set() with input as input // ❷
}

...

❶ test用のinputは、公式などではjsonで記述されていますが、jsonのメンテナンスはつらいので、yamlで記述管理してjsonに変換して使うと良さそうです。

❷ ルールのテストはこのように記述することができます。配列のメッセージ内容までチェックしなくていい場合は、そのエラーメッセージ配列が空なのか、そうじゃないのかをチェックするだけでも良さそうです。

# tag_rest.rego

package tags_validation

test_contain_proper_keys {
    tags := {"name": "test", "owner": "hoge", "description": "normal test"}
    contain_proper_keys(tags) // ❶
}

...

❶ mainのルール側で使っている関数のユニットテストはこのようにおこない、trueがかえればpassと判断されます

  • ここで書いたテストは、以下のコマンドで簡単に実行が可能です
# カレントディレクトリ配下のConftestのルールに対するテストを実行します
conftest verify ./

Conftestを実行するCIをGithub Actionで整備する

Conftest/Regoで書いたポリシールール自体のfmt/verifyのCIの設定

  • Regoで記述してConftestのコード自体のfmt/verifyのCIをまず設定します
name: conftest-fmt-and-verify-all

on:
  pull_request:
    branches: [ main ]
    paths:
      - 'policy/**'
env:
  CONFTEST_VERSION: 0.28.3
jobs:
  terraform:
    name: fmt-all
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Install conftest
        run: |
          wget -O - 'https://github.com/open-policy-agent/conftest/releases/download/v${{ env.CONFTEST_VERSION }}/conftest_${{ env.CONFTEST_VERSION }}_Linux_x86_64.tar.gz' | tar zxvf -
          ./conftest --version

      - name: conftest fmt
        run: |
          git add . && ./conftest fmt ./ && git diff --exit-code ./.    // ❶

      - name: conftest verify
        run: |
          ./conftest verify ./   // ❷

❶ terraformのvalidationのようなフラグがまだないので、conftest fmtの結果で差分が出たらテストが落ちるようにしています

❷ ここで先ほど書いたRegoで記述したポリシールールのテストを実行しています

Conftest testでTerraform plan結果をテストするCIの設定

  • 最後に、GIthub Action CIで記述したConftestのポリシールールで、Terraformコードを自動でチェックするようにしてみます
name: tf-plan-apply

on:
  pull_request: 
    branches: [ main ]

env:
  TF_VERSION: 1.0.0
  CONFTEST_VERSION: 0.28.3
  WORKING_DIR: ./
jobs:
  terraform:
    name: aws-eureka-pairs-etc-s3
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Install conftest
        run: |
          wget -O - 'https://github.com/open-policy-agent/conftest/releases/download/v${{ env.CONFTEST_VERSION }}/conftest_${{ env.CONFTEST_VERSION }}_Linux_x86_64.tar.gz' | tar zxvf -
          ./conftest --version //❶

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_wrapper: false //❷
          terraform_version: ${{ env.TF_VERSION }}
          cli_config_credentials_token: ${{ secrets.YOUR_CRED_NAME}}

      - name: Terraform Init ${{ env.WORKING_DIR }}
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform init

      - name: Terraform Plan ${{ env.WORKING_DIR }}
        if: github.event_name == 'pull_request'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        working-directory: ${{ env.WORKING_DIR }}
        id: plan
        run: terraform plan -out=tfplan -no-color -lock=false -parallelism=50

      - name: Convert terraform plan result to json formmat
        if: github.event_name == 'pull_request'
        id: convert
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform show -json tfplan > tfplan.json

      - name: conftest test
        if: github.event_name == 'pull_request'
        id: conftest
        run: ./conftest test --no-color ${{ env.WORKING_DIR }}/tfplan.json //❸

❶ 個人の公開している、Actionsできちんとメンテナンスされているものがなかったのでwgetでconftestをインストールするようにしています。

❷ 公式のhashicorp/setup-terraform を使っていると、デフォルトだとこの値がtrueになっていてplan結果が適切なjsonでshowできないのでfalseにしなければいけないことには注意が必要です。

❸ ここで先ほど記述したポリシールールでterraform plan結果をtestしていきます。使用するポリシーの読み込みは、デフォルトではpolicy/をみにいきます。別のディレクトリを参照させたい場合は、-pで渡して設定することができます。

終わりに

Conftest/OPA/Rego周りをいじってみての率直な感想としては、型信者なのでデータ定義でスキーマが欲しいなぁとか思ったり、論理評価の癖がかなり強くかつ表現力もかなり豊かなので、裏を返せばわかりやすくシンプルに書こうとしないとすぐにチームでメンテできなくなりそう、、と言う感じなので適切な設計/コード規約とともにメンテナビリティを意識して運用していきたいですね。

あとはなんといっても自分のコーディングスタイル的にもPolicy as Code自体のテストが簡単に書けるのは大きい。自信を持って、Regoのコードをがしがし変更/追加/リファクタしていけます。

また、ディレクトリ構成の戦略もいまだに各社模索していそうな雰囲気を感じるのでここらへんも試行錯誤しながらいい感じのところに落とし込んでいきたいです。

今後はEKSへのPlatform移行とともにKubernetes Manifestにも適応していったり、GitHub - open-policy-agent/gatekeeper: Gatekeeper - Policy Controller for Kubernetes の導入を検討したり、かなりPolicy as Codeを推し進めていきたいと考えているので、ここら辺やっていきたい方は是非弊社で一緒にやっていきましょう〜

Meetyもやってるのでお気軽のどうぞ!: 

meety.net

参考文献