Terraform経由でBigQueryのデータセットを管理する際に必要なメタデータが入力されていることをConftestで担保する

背景

権限管理を含め、BigQueryのデータセットの管理をTerraformで行なっている人は多いと思います。Terraformでデータセットを管理する際、descriptionlabelsなどのメタデータはデータマネジメント(データ品質やセキュリティ)でも重要です。

  • description
    • データセットやテーブルの棚卸しをする際、descriptionが入っていないと用途の把握などに時間がかかってしまいます
  • labels

こういった重要なメタデータはなるべく入力したいですが、慈善活動だと中々入力されないことが多いと思います(レビューで毎回指摘するおじさん役はやりたくない)。必要なメタデータが必ず入力される、validationされるということを保証するため、TerraformのModuleを使うアプローチを書きました。

ただ、TerraformのModuleの使い方は組織によってポリシーが色々あると思うので、Moduleを使うことなく素のgoogle_bigquery_datasetに対して制約を付けたい、というケースはあると思います。

ConftestによるTerraformのポリシーテスト

私は全然使ったことがなかったですが、Terraformを含む構造化された設定ファイル(KubernetesやDockerfileとかも対象にできるらしい)のポリシーテストをできるConftestというのがあるそうです。

例: ConftestでBigQueryのデータセットのlabelにownerが設定されていることをテストする

具体例を見たほうが雰囲気を掴めると思うので、実際に見ていきましょう。細かい文法は自分も全然まだ把握しきれていないですが、labelslabels.ownernullでないlabels.ownerが空文字でない、などを制約としてTerraformに対してポリシーテストを行なうことができます。

package gcp.missing_label_owner_google_bigquery_dataset

target_resources = ["google_bigquery_dataset"]

missing(r) {
    not r.change.after.labels
}

missing(r) {
    r.change.after.labels == null
}

missing(r) {
    not r.change.after.labels.owner
}

missing(r) {
    r.change.after.labels.owner == null
}

missing(r) {
    r.change.after.labels.owner == ""
}

deny_missing_label_owner_google_bigquery_dataset[{"msg": reason}] {
    resource := input.resource_changes[_]
    resource_type := resource.type
    resource_address := resource.address
    resource_actions := resource.change.actions[_]

    target_resources[_] == resource_type
    resource_actions != "no-op"
    resource_actions != "delete"
    missing(resource)

    reason := sprintf("`%v`: `owner` field in `labels` is missing", [resource_address])
}

実際のリソースに対してポリシーテストしてもいいですし、小さい事例を入力として手元でテストすることもできます。例えば以下のようなテストコードを書くことができます。

package gcp.missing_or_wrong_label_managed_by_terraform_google_bigquery_dataset

test_deny_missing_or_wrong_label_managed_by_terraform_google_bigquery_dataset__label_managed_by_is_undefined {
    reason := "`google_bigquery_dataset.test_dataset`: `managed_by` field in `labels` is missing or not terraform"
    input := {"resource_changes": [{
        "address": "google_bigquery_dataset.test_dataset",
        "mode": "managed",
        "type": "google_bigquery_dataset",
        "name": "test_dataset",
        "provider_name": "registry.terraform.io/hashicorp/google",
        "change": {
            "actions": ["create"],
            "before": null,
            "after": {
                "access": [{
                    "dataset": [],
                    "domain": "",
                    "group_by_email": "",
                    "iam_member": "",
                    "role": "READER",
                    "routine": [],
                    "special_group": "",
                    "user_by_email": "me@example.com",
                    "view": [],
                }],
                "creation_time": 1234567890,
                "dataset_id": "test_dataset",
                "default_collation": "",
                "default_encryption_configuration": [],
                "default_partition_expiration_ms": 0,
                "default_table_expiration_ms": 0,
                "delete_contents_on_destroy": false,
                "description": "",
                "effective_labels": {"owner": "developer"},
                "etag": "abcdefg",
                "friendly_name": "",
                "id": "projects/test-project/datasets/test_dataset",
                "is_case_insensitive": false,
                "labels": {"owner": "developer"},
                "last_modified_time": 1234567890,
                "location": "US",
                "max_time_travel_hours": "",
                "project": "test-project",
                "self_link": "https://bigquery.googleapis.com/bigquery/v2/projects/test-project/datasets/test_dataset",
                "storage_billing_model": "PHYSICAL",
                "terraform_labels": {"owner": "developer"},
                "timeouts": null,
            },
        },
    }]}
    deny_missing_or_wrong_label_managed_by_terraform_google_bigquery_dataset[{"msg": reason}] with input as input
}

test_deny_missing_or_wrong_label_managed_by_terraform_google_bigquery_dataset__label_managed_by_is_not_terraform {
    reason := "`google_bigquery_dataset.test_dataset`: `managed_by` field in `labels` is missing or not terraform"
    input := {"resource_changes": [{
        "address": "google_bigquery_dataset.test_dataset",
        "mode": "managed",
        "type": "google_bigquery_dataset",
        "name": "test_dataset",
        "provider_name": "registry.terraform.io/hashicorp/google",
        "change": {
            "actions": ["create"],
            "before": null,
            "after": {
                "access": [{
                    "dataset": [],
                    "domain": "",
                    "group_by_email": "",
                    "iam_member": "",
                    "role": "READER",
                    "routine": [],
                    "special_group": "",
                    "user_by_email": "me@example.com",
                    "view": [],
                }],
                "creation_time": 1234567890,
                "dataset_id": "test_dataset",
                "default_collation": "",
                "default_encryption_configuration": [],
                "default_partition_expiration_ms": 0,
                "default_table_expiration_ms": 0,
                "delete_contents_on_destroy": false,
                "description": "",
                "effective_labels": {"owner": "developer", "labels": "NOT_terraform"},
                "etag": "abcdefg",
                "friendly_name": "",
                "id": "projects/test-project/datasets/test_dataset",
                "is_case_insensitive": false,
                "labels": {"owner": "developer", "labels": "NOT_terraform"},
                "last_modified_time": 1234567890,
                "location": "US",
                "max_time_travel_hours": "",
                "project": "test-project",
                "self_link": "https://bigquery.googleapis.com/bigquery/v2/projects/test-project/datasets/test_dataset",
                "storage_billing_model": "PHYSICAL",
                "terraform_labels": {"owner": "developer", "labels": "NOT_terraform"},
                "timeouts": null,
            },
        },
    }]}

    deny_missing_or_wrong_label_managed_by_terraform_google_bigquery_dataset[{"msg": reason}] with input as input
}

手元でテストを実行する際はconftest verify --policy . --report failsという形で実行します。--report failsを付けないと落ちたテスト名しか出てこないので、何で落ちたか全然分からなくて困りました。

実際の業務への取り込み方

labelsが付いていないデータセットが多い状態でこれまで紹介したようなconftestを書くと、CIが落ちまくって大変なことになってしまいます。

  • 気合で最初はlabelsを埋めてしまう
  • それ以降のデータセットの追加はconftestでポリシーテストが常にされる

という進め方をする形になるかと思います(最後に頼れるの筋肉だけ...)。対象となるプロジェクトを絞る、など小さいスコープから始めるのも手ですね。また、descriptionはなかなか埋めるのが難しかったりするので、ひとまず埋めやすいlabels.ownerあたりから外堀を埋めていって「descriptionも当然書くよね...?」という雰囲気に持っていくのがいいかなぁと考えています。

データマネジメント / データガバナンスがきちんとされている状態を保っていくのも楽ではないですが、conftestなどこういったツールを適材適所で使って攻めにも工数を使っていきやすいようにしていきましょう。