🐕

Conftest: Terraform HCLファイルのポリシーテスト導入手順(3) ~ Rego(OPA)編 ~

2024/05/13に公開

3ヶ月ぶりのConftest続編となる。

最近、組織内のインフラプロジェクト横断でPolicy as Codeを実現するためのプロジェクトを実装した。

このプロジェクトではRegoで実装されたポリシーを集約していて、組織内の各インフラプロジェクトはConftestを使用してポリシーのダウンロードとポリシーテストの実行を行うような仕組みを提供する。

このプロジェクトの設計の詳細については次回の記事で紹介する予定だが、まずその前にこのプロジェクト(以後infra-policy-example)で定義されたRegoのコードをすべて読めるようにするための解説を今回は行いたい。

開発環境

Regoのコードを実装するにあたって、開発環境の構築が必要となる。

まずIDEだが自分の場合はVSCodeを使用していて、Regoのコード開発用にOPAプラグインを以下のコマンドでインストールしている。

このOPAプラグインではopa evalやtestコマンドをワークスペース上で実行できるといった機能などもあるようだが、個人的にはRegoのコードのハイライト目的だけで使用している。

$ code --install-extension tsandall.opa

VSCode以外の多くのIDEでもこのプラグインは使用可能であるため、自分の使用しているエディタが対応しているかは公式ドキュメントを確認してほしい。

次にRego向けのリンタなどを導入する必要があるが、セットアップ手順については先週投稿したRegalの記事を参考にしてほしい。

あとconftestコマンドやregalコマンドはこれまでの記事の過程で既にインストールされているはずだが、opaコマンドについてはインストール手順を明示してこなかったため、もしまだインストールを行なっていない場合はインストールを行う必要がある。

Macのhomebrewを使用している場合は以下のコマンドでインストール可能で、その他のインストール方法はこちら

$ brew install opa

サンプルプロジェクト

開発環境のセットアップや開発に必要なコマンドは揃ったので、次は基礎編、応用編で作成したサンプルプロジェクトをおさらいすることにする。

sample-projectのフォルダ構成は以下のようになる。

$ mkdir -p /tmp/sample-project/policies/terraform/google
$ mkdir -p /tmp/sample-project/policies/data

$ vi /tmp/sample-project/conftest.toml
# 設定内容は下記

$ vi /tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
# 設定内容は下記

$ vi /tmp/sample-project/policies/terraform/google/google_storage_bucket_test.rego
# 設定内容は下記

$ cd /tmp/sample-project/
$ tree -a
.
├── conftest.toml
└── policies
    └── terraform
        └── google
            ├── google_storage_bucket.rego
            └── google_storage_bucket_test.rego

4 directories, 3 files

久しぶりの話で自分同様に上記ファイルの内容を忘れている場合は、以下のおさらいを確認してほしい。

sample-project おさらい
/tmp/sample-project/conftest.toml
# ポリシーファイル(Rego)がまとめられているディレクトリパス(ルートから相対パスで指定)
# デフォルト値は"policy"
policy = "policies"
# Regoのプログラム内で、必要に応じて使うデータ(JSON、YAML等)がまとめられているディレクトリのパス
data = "policies/data"
# ポリシーの公開/配布等を考えないなら、とりあえずRegoのコードのpackage名とあわせておけばいい
namespace = "policies"
# ポリシーのテスト対象から除外するファイル、ディレクトリ
# .gitや.terraformに生成されるファイルは自分で作ったファイルではないのでチェックから除外
ignore = ".git/|.terraform/"
# warn発生時のExitCodeを1にする(デフォルトは0)
fail-on-warn = true
/tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
package policies

# google_storage_bucketリソースにpublic_access_preventionを定義していない場合、ルール違反
deny_public_access_prevention[msg] {
  some name
  bucket := input.resource.google_storage_bucket[name]
  not has_field(bucket, "public_access_prevention")
  msg := sprintf("`public_access_prevention` argument isn't defined for google_storage_bucket.%v", [name])
}

# google_storage_bucketリソースのpublic_access_preventionに"enforced"以外の値を指定した場合、ルール違反
deny_public_access_prevention[msg] {
  some name
  public_access_prevention := input.resource.google_storage_bucket[name].public_access_prevention
  public_access_prevention != "enforced"
  msg := sprintf("google_storage_bucket.%v.public_access_prevention is `%v` instead of `enforced`", [name, public_access_prevention])
}

# ConftestのExamplesからコピー拝借してきた(割とExamples内でみんな使ってる関数)
# https://github.com/open-policy-agent/conftest/blob/master/examples/serverless/policy/util.rego
has_field(object, field) {
  object[field]
}

has_field(object, field) {
  object[field] == false
}

has_field(object, field) := false {
  not object[field]
  not object[field] == false
}
/tmp/sample-project/policies/terraform/google/google_storage_bucket_test.rego
package policies

# そもそも`public_access_prevention`が定義されていない場合、ルール違反となることを確認
test_no_public_access_prevention {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
    }`)
  deny_public_access_prevention["`public_access_prevention` argument isn't defined for google_storage_bucket.name"] with input as cfg
}

# `#public_access_prevention = "enforced"`のようにコメントアウトされている場合、ルール違反となることを確認
test_comment_out_public_access_prevention {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
      #public_access_prevention    = "enforced"
    }`)
  deny_public_access_prevention["`public_access_prevention` argument isn't defined for google_storage_bucket.name"] with input as cfg
}

# `public_access_prevention = "inherited"`のように意図と違う値が設定されている場合、ルール違反となることを確認
test_public_access_prevention_inherited {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
      public_access_prevention    = "inherited"
    }`)
  deny_public_access_prevention["google_storage_bucket.name.public_access_prevention is `inherited` instead of `enforced`"] with input as cfg
}

# `public_access_prevention = "enforced"`が設定されている場合、ルールにすべてパスする
test_public_access_prevention_enforced {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
      public_access_prevention    = "enforced"
    }`)
  count(deny_public_access_prevention) == 0 with input as cfg
}

OPA v1対応

サンプルプロジェクトのRegoのコードはConftestのドキュメントの情報のみを元に実装したものであって、OPAの公式ドキュメントで紹介されているRegoの構文などをみるとその能力をすべて発揮できているとは言い難い。

そのためここではまずサンプルのコードを最新の記法に書き換えていくことにする。

現時点でOPAの最新のバージョンはv0.64.1だが、今のうちからv1.0.0へのメジャーバージョンアップに備えてそれらの構文を使用することも出来る。

OPA v1のオプトインは以下の行を追加することで可能となる。

import rego.v1

試しにgoogle_storage_bucket.regoにこのimport文を追加してconftestコマンドを実行すると、以下のようなエラーが発生するようになる。

$ conftest test .
policies/terraform/google/google_storage_bucket.rego:6: rego_parse_error: `if` keyword is required before rule body
policies/terraform/google/google_storage_bucket.rego:6: rego_parse_error: `contains` keyword is required for partial set rules
...

これはdeny_public_access_prevention[msg]という記法はv1では使えなくなったことを表していて、以下のような構文に書き直す必要がある。

import rego.v1

deny_public_access_prevention contains msg if {
  ...
  msg := sprintf("`public_access_prevention` argument isn't defined for google_storage_bucket.%v", [name])
}

まずv1ではルールの末尾に必ずifを追加しなければいけないため追加を行なっている。

そしてdeny_public_access_prevention contains msgは初見では何を意味している全くかわからないものだが、これは deny_public_access_preventionはset型(=contains)の値 であり、このsetにはmsgの値が追加されるという2つのことをあらわしている。

もしこの説明であまり理解できなかったとしても、とりあえずこれまではルールの定義の際に必ずdeny_xxx[msg]と書いていたのを、v1以降はdeny_xxx contains msg ifと書けばいいだけと捉えればいい。

関数

次に、ポリシー側とテストコード側で以下のエラーメッセージが何度もリテラル定義されている点を改善したい。

deny_public_access_prevention contains msg if {
  ...
  msg := sprintf("`public_access_prevention` argument isn't defined for google_storage_bucket.%v", [name])
}

これはエラーメッセージを関数化してポリシー側とテストコード側両方から呼び出すことでエラーメッセージの生成をDRYにすることが出来る。

package policies

import rego.v1

msg_public_access_prevention1(name) := sprintf(
  "`public_access_prevention` argument isn't defined for google_storage_bucket.%v",
  [name]
)

deny_public_access_prevention contains msg if {
  ...
  msg := msg_public_access_prevention1(name)
}

ちなみに関数の代入式では:=を使用しているが=に変えても問題なく動作する。

2つの違いだが:=は代入のみが実行されるのに対して、=は比較と代入を同時に行う式となる。

具体的な例として、以下のコードをPlaygroundで実行してみるとわかりやすい。

package play

import rego.v1

allow := decision if {
  [x, "world", "!"] = ["hello", y, "!"]
  decision := {"x": x, "y": y}
}

allowルール1行目では=で代入と比較を行なっているため、allowおよびdecisionには{"x":"hello", "y":"world"}という値が代入されるのに対して、1行目を:=に変更すると比較が行えないためにエラー終了となる。

とはいえこのような処理が必要になるケースは稀な気がしているので、基本的に代入が必要な際は:=で統一した方がいい気がしている。

Metadata

ここからはmetadataによるアノテーションコメントの書き方を説明するが、基本的にはルールごとに以下のようなコメントを記載するだけである。

(metadataの記述方法の詳細は公式ドキュメントを参照)

package policies

import rego.v1

# METADATA
# description: |
#  google_storage_bucketリソースにpublic_access_preventionを定義していない場合、ルール違反
# authors:
# - name: fittecs
# related_resources:
# - ref: https://cloud.google.com/storage/docs/public-access-prevention?hl=ja
#   description: 公開アクセス防止設定に関するドキュメント
# custom:
#  severity: MEDIUM
deny_public_access_prevention contains msg if {
  ...
}

アノテーションコメントの1行目には必ずMETADAと書く必要がある。

次のdescriptionではルールの詳細の説明、authorsにはルール実装者の名前やメールアドレスを記述する。

次のrelated_resourcesにはルールを実装する際に参考したページのURLや説明を記載しているが、これはdescriptionと共に知見の共有に大いに役立つため、丁寧に記述しておいた方がいいアノテーションとなる。

最後のcustomは任意のkey-value値を定義可能なアノテーションで、特にseverityについては以下のようにルール内の処理から参照するようなサンプル実装を度々目撃することになるはずである。

decision := {
  # customアノテーションに定義したseverityの値を取得(MEDIUM)している
  "severity": rego.metadata.rule().custom.severity,
  "msg": msg_public_access_prevention1(name),
}

なおseverityを指定出来るようにしたところでConftestの実行時の挙動が変わるわけではなく、あくまでルール実装者がルールの重要性がどれほどのものかを示すだけのものとなっている。

ちなみに前回の応用編でdenyルールとviolationルールの違いについて、上記decisionのような構造化データを渡せるか否かの違いがあると説明したが、実際に実装してみたところ構造化データの中に変数msgさえ含まれていればdenyでも構造化データを扱えることが判明した。

よって今後自分のプロジェクトではルールの戻り値として、msgではなく上記decisionのような構造データを返すようにしている。

ここまでに行なった修正をまとめた結果は、infra-policy-exampleプロジェクトの以下のファイルで確認することが出来る。

なおサンプルではファイル名がgoogle_storage_bucket.regoだったのが、現在は属性名であるpublic_access_prevention.regoに変更されていて、パッケージ名もpoliciesから変更されている。

パッケージ

Regoで実装されたポリシーを社内で一元管理するケースでは、パッケージの分割が極めて重要となってくる。

これまではパッケージ名をpoliciesで統一してきたが、この方針で社内リポジトリを設計すると同じルール名、関数名などが他のRegoモジュール(=ファイル)で使用されていないかを都度全体検索しながら実装する必要が出てくるため、これは現実的なソースファイル管理方法ではない。

この問題を解決するために、infra-policy-exampleではパッケージ名をディレクトリパスと同期させるルールを採用している。

public_access_prevention.regoはpolicyディレクトリから見て、conftest/terraform/gcloud/google_storage_bucketディレクトリ配下に存在するため、パッケージ名は以下のようになっている。

package conftest.terraform.gcloud.google_storage_bucket

ちなみにサンプルプロジェクトではgoogle_storage_bucket.regoといったようにTerraformリソース単位でルールをまとめていた。

しかしこの粒度だと、google_storage_bucketリソースの全属性のルールが1つのモジュールに定義されてしまうことになり、ファイルが肥大化してしまうことから、infra-policy-exampleではさらにもう1階層リソース名(google_storage_bucket)でディレクトリを切っていて、その中でリソースの属性ごとにポリシーファイルを定義するようにしている。

あと各ディレクトリの中には_package.regoというモジュールを定義しているが、これはパッケージ内で1度しか定義できないpackageスコープのmetadataアノテーションを記述するのと、パッケージ内で共通の関数を定義するために用意したものとなる。

なお_package.regoというファイル名は完全に自分の趣味でつけた名前であるため、今後OPA公式が正式な名前を提供したり、他に良い名前があるならそちらを使用した方がいい。

あと、パッケージ名を変更した場合はconftest.tomlからnamespaceの設定を消す必要がある。

共通化

あるパッケージから別のパッケージのモジュールに定義されている関数を呼び出す方法について説明する。

サンプルプロジェクトでは以下のようなhas_field関数をgoogle_strage_bucket.regoに直接埋め込んで使用していたが、本来は汎用的な利用が想定される関数であるため、別途util.regoとして切り出すべきである。

# ConftestのExamplesからコピー拝借してきた(割とExamples内でみんな使ってる関数)
# https://github.com/open-policy-agent/conftest/blob/master/examples/serverless/policy/util.rego
has_field(object, field) {
  object[field]
}
...

infra-policy-exampleではutil.regopublic_access_prevention.regoの階層(=パッケージ)関係は以下のようになっている。

$ tree -a policy/
policy/
├── conftest
    ├── terraform
    │   ├── gcloud
    │   │   ├── google_storage_bucket
    │   │   │   ├── _package.rego
    │   │   │   ├── public_access_prevention.rego
    │   │   │   └── public_access_prevention_test.rego
    │   │   └── ...
    │   └── ...
    └── util.rego

util.regoはconftestパッケージに属しているが、public_access_prevention.regoからutil.regoのhas_field関数を呼び出す場合のimport文と呼び出し方は以下のようになる。

public_access_prevention.rego
package conftest.terraform.gcloud.google_storage_bucket

import rego.v1

import data.conftest

...

deny_public_access_prevention contains decision if {
...
  not conftest.has_field(bucket, "public_access_prevention")
...
}

応用編でもdataについては触れたが、パッケージへのアクセスについてもdata経由行う必要がある。

まずimport文ではパッケージ名さえ書けばモジュール内の関数にアクセスが可能となり、関数はパッケージ名.関数名で呼び出しを行う。

コマンド

ここからはRegoのコード開発時に使用するコマンドについて紹介する。

ポリシーテスト

(以降のコマンドはプロジェクトルートで実行されることを想定している)

$ conftest test --all-namespaces .

Conftestを使用してプロジェクト内の設定ファイル群に対してポリシーテストを実行するためのコマンドだが、ポリシー内にパッケージ名が複数存在する場合は--all-namespacesオプションを必ずつける必要がある。

テストコード

$ conftest verify --show-builtin-errors 

応用編で既に紹介したコマンドで、*_test.rego内にあるtest_から始まるテストコードを実行するコマンドとなる。

なおconftest.tomlでpolicyの設定を行なっている場合は、コマンド実行時にポリシーが格納されているディレクトリの指定は不要となる.

フォーマッタ

$ opa fmt --v1-compatible --rego-v1 -w ./path/to/policy-dir/

OPA v1互換のフォーマッタの適用は以上のコマンドで行う。

一方CIではフォーマッタが適用されているかのチェックを行う必要があり、その場合は以下のようなコマンドを実行することになる。

$ opa fmt --v1-compatible --rego-v1 --fail --list ./path/to/policy-dir/

opa fmtコマンドの詳細についてはこちらを参照。

リンタ

$ regal lint ./path/to/policy-dir/

既にRegalの記事で説明したが、Regoのコードに対するリンタの実行は以上のコマンドで行う。

ドキュメント

$ opa inspect -a ./path/to/policy-dir/

ルールにmetadataコメントを記述している場合、上記コマンドを実行するとコメント情報を元にドキュメントを出力してくれる。

opa inspectコマンドの詳細についてはこちらを参照。

TIPS

ここからはさらにinfra-policy-exampleの解読やRegoのコード実装に役立つトピックをいくつか紹介していくことにする。

コンテキスト情報

Conftest経由でRegoのコードを実行する場合、data.conftestというコンテキスト情報をあらわす値を参照出来るが、応用方法として例えばTFLintの設定ファイル(.tflint.hcl)にHashiCorpのスタイルガイドで推奨されているルール(terraform_naming_convention, terraform_comment_syntax)が定義されているか確認するポリシーを実装するケースを想定してみる。

この場合ファイル内にterraform_naming_conventionが定義されていない場合エラーを返すというルールを記述することになるが、Conftestでは対象ディレクトリ内の全ファイルにルールを適用してテストを実行するため、ほぼ全ファイルでterraform_naming_conventionが存在しないというエラーが発生してしまう。

この問題に対して、.tflint.hclファイルに対してのみルールを適用するようにしなければいけないが、data.conftest.file.name == ".tflint.hcl"のようなルールを追加することで、チェックの対象を限定できるようになる。

実際の.tflint.hclのポリシーテストのコードはこちら

リンタとフォーマッタ

Regalでは1行あたりの文字数が120文字を超えるとエラーとなるline-lengthルールが存在する。

なおopa fmtコマンドによるフォーマッタ適用ではこのルールを考慮しないため、120文字を超える行が出てきた場合は、改行可能な箇所で改行を挟むといった手動での対応が必要になる。

なお.editorconfigで対応可能か調べてみたが、EditorConfig for VS Codeはmax_line_lengthをサポートしていないようなので、この方法でもフォーマットの調整は不可能となる。

ただRegoのコードをGithub上で表示すると、デフォルトのインデントスペースが8文字となって非常に見辛いので、.editorconfigで以下のような設定を行う必要はある。

# 慣例的にプロジェクトルートの.editorconfigに設定する値
# EditorConfigは階層を遡って.editorconfigを探索していき、root=trueが設定されているファイルに到達したら探索を終了する
root = true

# Github上でRegoのコードを表示する際にデフォルトだとインデント幅が8スペースになって見辛いので以下を設定
# ref. https://docs.styra.com/regal/rules/style/opa-fmt#rationale
[*.rego]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = tab
indent_size = 2
max_line_length = 120

(実例はこちら)

テスト

以下のようにテストコードのルール名の先頭にtodo_を付けることで、そのテストを一時的にスキップできるようになる。

/tmp/sample-project/policies/terraform/google/google_storage_bucket_test.rego
package policies

import rego.v1

# このテストはスキップされる
todo_test_no_public_access_prevention if {
  ...
}
...

ただしテストスキップを使用する場合、todo_を外すのを忘れてそのままCIをパスしてしまうのを防ぐために、Regalのtodo-testルールは有効のままにしておく必要がある。

あとconftest verifyコマンドはデフォルトで全テストファイルを実行するが、特定のディレクトリのテストだけを実行したい場合は以下のように-pオプションで実行対象を絞り込むこともできる。

(とはいえテストコードの実行は一瞬で完了するため、特に出番はない気がしている)

$ conftest verify -p path/to/test-dir/ --show-builtin-errors

他、テストコードではparse_config関数を使用して擬似的なHCLファイルなどを定義してきたが、こちらのコードのようにparse_config_file関数を使うことでテスト用のファイルを切り出すことも可能となっている。

ドキュメント

Regoの構文は通常の手続き型言語とは明らかに異なるため、Regoのコードを本格的に実装していく際にはOPA公式の以下のプログラミングに関するドキュメントだけは読んでおいた方が良い。

いきなり上記ページを読むのは面倒ではあるのでPython、Java、Goをよく利用している人であれば、各プログラム言語でルールを実装した場合とRegoで実装した場合の比較サンプルを手始めに確認してからの方が理解が捗るかもしれない。

ちなみに上記リンクを含めこれまでOPAに関するさまざまなページやツールを紹介してきたが、多くはこちらのページから拝借させてもらったものとなっている。

このページの中でも個人的に特にイチオシのawesome-opaでは、メジャーなOPA関連プロダクトをリスト化してくれているため、Regoで何かしらのポリシーを実装する際にはまず自分の用途に合致するプロダクトやポリシーリポジトリが既に存在しないかを確認した方がよい。

pre-commit

pre-commitを使用することでコミット時に、Conftestを実行してポリシーに違反したコードが含まれていないかをチェックすることが出来る。

なおconftestコマンドによるポリシーテスト実行に関係するファイルはプロジェクト内のほぼ全ファイルであるため、トリガーは以下のように常に発火(always_run:true)で設定する必要がある。

repos:
  - repo: local
    hooks:
      - id: conftest
        name: run conftest
        entry: conftest test --all-namespaces .
        language: system
        always_run: true

(実例はこちら)

おわり

これまでのConftestの解説記事の内容さえ理解できれば、Rego(OPA)とConftestの実践例であるinfra-policy-exampleプロジェクトのコードも理解できて、あとは公式ドキュメントを読めば追加改修なども問題なく行えるようになるはずである。

そして次回はPaCの総決算となるが、infra-testing-google-sampleプロジェクトのv0.4.0(Conftest導入)の設計についてまとめる。

追記

総決算。

https://zenn.dev/erueru_tech/articles/0064d0c9902b2a

Discussion