🦔

Conftestによる社内の全インフラプロジェクトのPolicy as Code管理 v0.4.0

2024/05/20に公開

これまでの改修によってTerraformのテストコードの実装環境の整備が一段落したため、ここからはさらに他のテスト機能をプロジェクトに追加していくことになる。

今回のバージョンアップでは、インフラプロジェクトの様々な設定に対する社内ポリシーを一元管理するプロジェクトとして新たにinfra-policy-exampleを作成した。

このプロジェクト内で定義されたRegoのコードを、infra-testing-google-sampleなどの各インフラプロジェクトでダウンロード&実行することでポリシーテストを実現する。

具体的な手順として、各インフラプロジェクトのCIやpre-commit実行時に以下のコマンドを実行するだけで、HCLファイルなどを始めとした各種設定ファイルに社内のポリシーに反する設定が含まれていないかをテストすることが出来るようになる。

$ cd /path/to/your-infra-project

# infra-policy-exampleプロジェクトのpolicyディレクトリを、各自のインフラプロジェクトのルート直下にorg-policiesというディレクトリ名でダウンロード
# なお各自のプロジェクトでGit管理されないようにするために.gitignoreでorg-policiesを定義する必要がある
$ conftest pull git::https://github.com/erueru-tech/infra-policy-example.git//policy -p org-policies

# org-policiesディレクトリ内に定義された全ルールのテストを実行
$ conftest test -p org-policies --all-namespaces .

そして今回の記事ではこの社内ポリシーを管理するinfra-policy-exampleプロジェクトのコンセプトや実装について掘り下げていきたい。

infra-policy-example

まずはプロジェクトを構成するファイルやディレクトリについて一つずつ説明していきたい。

policy

社内ポリシーを定義したRegoファイルを保存しているpolicyディレクトリだが、その構造は以下のようになる。

$ cd /path/to/infra-policy-example
$ tree -a
.
├── policy
│   ├── conftest
│   │   ├── _package.rego
│   │   ├── util.rego
│   │   ├── terraform
│   │   │   ├── eol
│   │   │   │   ├── _package.rego
│   │   │   │   ├── required_version.rego
│   │   │   │   └── required_version_test.rego
│   │   │   ├── gcloud
│   │   │   │   ├── google_storage_bucket
│   │   │   │   └── sql_db
│   │   │   │       └── mysql
│   │   │   │           ├── ...
│   │   │   └── tflint
│   │   │       ├── ...
...

policy直下ではconftestというディレクトリを切っているが、これはTrivyやTFLintといったRegoを実行可能なツールごとにRegoファイルを分離するための階層となっている。

これはConftest限定でしか動かないparse_config関数が含まれるRegoのコードを他のツールで実行されないようにするためのパーティショニングとなっている。

次の階層ではterraformディレクトリを置いているが、ここでもCloudFormation、Pulumiなど使用するIaCツールごとにRegoファイルを分離出来るようにしてる。

ここまで説明すればさらにその中にあるeol、gcloud、tflintディレクトリの意味については大体想像出来ると思うが、これらパッケージの詳細については「ポリシー」で後述する。

なおConftestのディレクトリ構造についてはデファクトスタンダードが現状存在しないため、当面は自分で試行錯誤しながら決めていくしかない状況となっている。

ディレクトリ構造に関してはプロジェクトが小さいうちはあまり気にする必要がないかもしれないが、リポジトリ内のルールが肥大化してくると全てのRegoファイルをダウンロード&実行させるのは効率が悪いことから、Regoファイルの部分ダウンロードを行いたいといった要望が出てくることが予想される。

その場合ディレクトリ構造が非常に重要となってくるため、可能な範囲で将来を想定した上でディレクトリ構造を決めておいた方が良い。

(なおOPAやConftestによるRegoコードの実行は非常に高速であるため、よほど大きなプロジェクトにならない限りは問題にならないかもしれないが)

Github Actions、pre-commit

.
├── .github
│   ├── actions
│   │   └── slack
│   │       └── action.yaml
│   └── workflows
│       └── ci.yaml
├── scripts
│   ├── conftest.sh
│   ├── opafmt.sh
│   └── regal.sh
├── .pre-commit-config.yaml
...

上記ファイル群はGithub Actionsやpre-commitでRegoのコードのフォーマットチェックやリンタ、テストコードを実行するために必要なものとなるが、これらファイルについては後ほど「CI」で別途詳細を説明する。

その他設定ファイル

.
├── .regal
│   ├── config.yaml
│   └── rules
├── conftest.toml
└── .editorconfig

infra-policy-exampleではその他にRegalやConftest、エディタの設定ファイルといったRegoの開発に必要なファイルを定義しているが、これらのファイルは既にConftestの一連の記事やRegalの記事で説明したものばかりなので、ここでは説明を省略する。

なおこれまでの記事でconftest.tomlの設定方法を紹介してきたが、infra-policy-exampleプロジェクトでは最終的にignore = ".git/|.terraform/|LICENSE"しか設定が残らなかった点には言及しておきたい。

ポリシー

policyディレクトリでは社内のポリシーを定義したRegoファイルが格納されていると先ほど説明したが、サンプルとして実装したパッケージでは以下のようなチェックを行なっている。

🔗 eol

eol(end of life)パッケージではプログラミング言語やフレームワーク、ライブラリのバージョンが一定以上であることを要求する以下のようなEOLルールを定義している。

package conftest.terraform.eol

import rego.v1

hashicorp_support_verion := "1.7.0"

msg_required_version1 := "the terraform version used by your project has reached its end of life (EOL)"

warn_required_version contains decision if {
  required_version := input.terraform.required_version
  semver.compare(hashicorp_support_verion, required_version) == 1
  decision := {
    "severity": rego.metadata.rule().custom.severity,
    "msg": msg_required_version1,
  }
}

このルールではプロジェクト内のHCLファイル群のどこかで定義されているrequied_versionの値がTerraformの推奨するバージョン以上であることをチェックしている。

HashiCorp社によると、Terraformは直近2リリースまでのバージョンの利用が望ましいとしているため、現在の最新バージョンが1.8.3であることを考えると1.7.0未満のバージョンは非推奨ということになる。

ちなみにTerraformのマイナーバージョンアップデート日はプログラムで自動的に予測出来ないことから、hashicorp_support_verion := "1.7.0"のようにしきい値を手動で設定するような運用方法となっている。

(Terraformのマイナーバージョンアップ後、仮にこのポリシーを更新しなかったとしても実害は発生しないので、手動運用でも問題ない)

ルール内の処理の特徴としてはバージョンの比較の際に、Regoの標準APIであるsemver.compareを使用している点がある。

Regoのビルトイン関数ではこの他にも一般的なプログラミング言語において標準で提供しているような関数を始め、UUID、CIDR計算、グラフ処理などの多くの拡張機能も持っているので、ルールを記述する前に必要となる関数が既に実装されていないかリファレンスに目を通しておいた方が良い。

なおEOLのチェックは多少古いバージョンを使用していたからといって、CI/CDを停止させるほどの問題でもないので、ルール名はdenyやviolationではなくwarn_required_versionとしている。

ただし特定のフレームワークやライブラリの特定のバージョンで致命的なセキュリティリスクが確認された場合等に関しては、新たにdenyやviolationルールを追加するといった運用を想定している。

🔗 google_storage_bucket

このパッケージについてはConftestの一連の記事、特にRego(OPA)編で詳細に扱っているためここでは説明を省略するが、Terraformのgoogle_storage_bucketリソースの定義に関連するルールをまとめたパッケージとなっている。

🔗 sql_db/mysql

Google公式が提供するCloud SQLリソースを作成するためのモジュールであるsql-dbのMySQL設定に関するポリシーをまとめたパッケージとなる。

このパッケージではdb_charset.regodb_collation.regoという2つのRegoファイルでポリシーを定義しているが、やっていることはどちらもMySQLのcharacter_set関連の設定で、絵文字などの保存が可能なutf8mb4の使用を強制するルールを定義している。

ルール自体はその他のパッケージのルールの定義と特に変わり映えはしない処理で構成されているため追加で説明することはないが、Conftestのexceptionを使用して特定のインフラプロジェクトに対してはdb_charsetルールを適用しないようにする方法を試している点だけは触れておきたい。

package conftest.terraform.gcloud.sql_db.mysql

...

# METADATA
# description: db_charsetの値は定義必須
# ...
violation_db_charset contains decision if {
  ...
}

# METADATA
# description: |
#  古くから運用されているデータベースではdb_charsetの値がutf8のままで、かつ変更も容易ではない可能性がある
#  そのようなケースではexceptionでルールチェックを免除する
# authors:
# - name: fittecs
exception contains rules if {
  # 除外対象のインフラプロジェクトのリポジトリ名を以下に定義
  ignore_projects := ["ignore-project"]
  project := ignore_projects[_]
  contains(data.conftest.file.dir, sprintf("/%v/", [project]))
  rules := ["db_charset"]
}

利用方法は簡単でignore対象のインフラプロジェクト名をignore_projectsに定義することで、そのプロジェクトでポリシーテストを実行する際にrulesで指定されたルールのチェックを行わないようにすることが出来る。

(exceptionはConftest応用編にて説明しているので、詳細を知りたい方はこちら)

なおexceptionルールはRegoファイルごとに別のルールを除外する定義を行うことが可能となっている。

ただしexceptionは除外対象となるルールと同じパッケージ内に定義する必要がある点には注意。

社内の全インフラプロジェクトにおいて、常にinfra-policy-example内の全ルールを問題なく適用出来るとは考えづらいため、実際の運用においてはこのexceptionの定義が必要となるケースは定期的に出てくるかと思われる。

ただ例外を設ける前に、なぜ適用出来ないのかやルール自体に問題がないかをその都度話し合うべきではあるかと思う。

🔗 tflint

TFLintの設定ファイルである.tflint.hclファイルの定義がHashiCorp社が提供するスタイルガイドに準拠した設定になっているかをチェックするルールをまとめたパッケージとなる。

このパッケージはRego(OPA)編でも紹介したConftestのコンテキスト情報やparse_config_file関数を使用したテストコードを実装している点くらいしか特に見所がないので、詳細が気になる人はRegoのコードを追ってみてほしい。

CI

infra-policy-exampleのCIの仕組みだが、ワークフローの実装は基本的にv0.3.0の記事で紹介した方法の派生でしかないため大枠についてのみ簡単に説明する。

infra-policy-exampleのワークフローを定義したci.yamlはプロジェクトのチェックアウトを行ったのち、Regoのフォーマッタ適用、リンタの実行、テストコードの実行を行ったのち、Slackのチャンネルにワークフローの実行結果を通知するだけといった非常にシンプルなものとなっている。

このワークフローを構成するスクリプトは以下のようになるが、opa、regal、conftestコマンドをほぼ素で実行するのと大差の無いものとなっている。

フォーマッタ適用 / opafmt.sh

#!/bin/bash

set -eu

# pre-commitなどローカルから実行された場合、環境変数CIは定義されていないためフォーマッタの適用(-w)が実行される
# 一方Github Actions上で実行される場合、環境変数CI=trueが自動的に設定されるため、フォーマットチェック(--fail --list)を実行する
[[ -z ${CI:-} ]] && OPT="-w" || OPT="--fail --list"
opa fmt --v1-compatible --rego-v1 $OPT policy/

リンタの実行 / regal.sh

#!/bin/bash

set -eu

# policyディレクトリ配下のRegoファイルに対してリンタを実行
regal lint policy/

テストコードの実行 / conftest.sh

#!/bin/bash

set -eu

# テストコードの実行
conftest verify --show-builtin-errors
conftest test --all-namespaces .

なおinfra-policy-exampleの最終成果物はmainブランチのRegoファイルであることから、PRマージ後にデプロイ等を行うようなワークフローは不要となる。

補足

最後に社内ポリシープロジェクトの運用に関して、いくつか個人的な意見やアイディアを残しておきたいと思う。

運用

まず1つ目に、社内ポリシープロジェクトのリポジトリにはインフラ関係者全員にReadおよびWrite権限を与えて良いのではと個人的には考えている。

このプロジェクトは社内ポリシーテストの実行において役立つのはもちろんだが、インフラプロジェクトの設定に関するナリッジの集積場所としても非常に価値があるため、出来れば多くのメンバーを巻き込んだ方がよい。

そしてルールの実装者各自がmetadataコメントを丁寧にドキュメント化することで、熟練者の設定方法をインフラメンバ間で共有することができるようになる。

あとルールにバグが混入した場合、最悪のケースを考えても各インフラプロジェクトのCI/CDが一時的に停止するだけであって、これはコミットのrevertを行うだけですぐに復旧が可能なので、それならばより多くのメンバーをルール追加に参加させた方が良い気がしている。

テスト

ポリシーテストで記述するルールはTerraformのテストコードでも実装可能なケースがしばしばある。

ポリシーテスト、Terraformのテストコードどちらでもテストが記述可能な場合、基本的には実行時間が圧倒的に高速なポリシーテスト側で記述した方が良い。

ちなみにだが、gcloudコマンド+Regoの組み合わせによって、TerraformのテストコードやContinuous Validationを代替することが出来るではないかと考えている。

例として、Compute Engineで起動しているインスタンスの中に許可されていないマシンタイプを使用しているものがないかをチェックするテストは以下のように実現できる。

(本当はTerraform Cloud x Sentinelののように今月の請求金額が一定以上になっていないかをチェックするルールを例にしたかったが、その場合課金レポートのBQエクスポートが必要となり、単純にgcloudコマンドだけで料金情報を取得出来ないようなので断念)

$ cd /tmp/

# インスタンス一覧情報をJSONで出力
$ gcloud compute instances list --format=json > instances.json

# ポリシーの作成
$ mkdir policy
$ vi policy/machine_type.rego
# ルールの詳細は下記

# ポリシーテストの実行
# n1-standard-1とt2d-standard-1の2台のインスタンスを起動した状態でテストを実行した場合、以下のようなresultが返る
$ opa exec --decision=main/deny_machine_type --bundle policy/ instances.json
{
  "result": [
    {
      "path": "instances.json",
      "result": [
        "running `instance-20240519-074812` instance with disallowed machine type `t2d-standard-1`"
      ]
    }
  ]
}
/tmp/policy/machine_type.rego
package main

import rego.v1

allowed_machine_types := [
  "n1-standard-1",
]

# gcloud compute instances listコマンドの実行結果は名前のないJSON配列となるため、input[_]でインスタンスを1つずつイテレーションしている
# またmachineTypeのフォーマットは以下のようになる
# "https://www.googleapis.com/compute/v1/projects/infra-testing-google-sample/zones/us-central1-a/machineTypes/n1-standard-1"
deny_machine_type contains reason if {
  ins := input[_]
  machine_type := array.reverse(split(ins.machineType, "/"))[0]
  not machine_type in allowed_machine_types
  reason := sprintf("running `%v` instance with disallowed machine type `%v`", [ins.name, machine_type])
}

あとはこのルールをCloud Schedulerなどのcronサービスで定期的に実行するだけでContinuous Validationを実現できる。

なおこの程度ならRegoで書かなくてもシェルだけで実装出来る気もするが、array.reverseやsplit関数などを使える時点でシェルと比べて遥かに実装しやすい上に分かりやすいコードになる。

おわり

Regoによるポリシーテストはシステムの世界に留まらず、この世のどんな事象であれJSONで表現することさえ出来れば、それらの状態に対して意図通りであるかを判定することが可能となる。

アイディア次第では一企業の一システムの枠を超えて、社会に対して大きな価値を生み出すことも可能なのではないかと個人的には考えているため、PaC界隈の今後の発展にはかなり期待している。

以上でインフラプロジェクトへのPaC導入は完了で、次のバージョンアップでTrivyの導入が完了すれば基本的なテスト機能はほぼ揃うことになる。

なお今回紹介したポリシーのサンプルは設定値のチェックがメインとなっていて、セキュリティ関連のチェックはまったく登場しなかったが、この点に関してはTrivyの導入と共に整理していく予定。

続き

https://zenn.dev/erueru_tech/articles/3a93cdfd88b3cd

Discussion