🛠

CDK for Terraformでfor_eachをなんとか表現してみた

2022/09/15に公開

こんにちは、クラウドエースの阿部です。
これまでCDK for Terraform(以降、CDKTF)について以下のような記事を作成しました。

https://zenn.dev/cloud_ace/articles/cdk-for-terraform-startup

https://zenn.dev/cloud_ace/articles/cdk-for-terraform-functions

Terraformでは、複数リソースを作成するとき、1つ1つリソースを定義せず可変させたいパラメータのみリスト化してfor_eachメタ引数を使って複製する方法が常套手段になっています。
しかし、ざっくり調査した限りではComplex Typeのリストをfor_eachに入れる事ができずTerraform HCLのような書き方ができなかったのでがっかりしていました。

この記事はfor_eachについてもう少し何とかならないかなぁと頑張ってみた記録になります。

Terraform HCLで表現できる内容

Terraform HCLでは以下のように書けるコードを、CDKTFでどう書けるか探っていきたいと思います。

sample.tf
locals {
  region = "asia-northeast1"
  vm_configs = [
    {
      name         = "bravo"
      zone         = "asia-northeast1-a"
      machine_type = "e2-medium"
    },
    {
      name         = "charlie"
      zone         = "asia-northeast1-b"
      machine_type = "e2-micro"
    },
    {
      name         = "delta"
      zone         = "asia-northeast1-c"
      machine_type = "f1-micro"
    }
  ]
}

resource "google_compute_network" "network" {
  name                    = "cdktf-test"
  routing_mode            = "REGIONAL"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "subnetwork" {
  name                     = "cdktf-test"
  ip_cidr_range            = "10.10.0.0/16"
  network                  = google_compute_network.network.self_link
  region                   = local.region
  private_ip_google_access = true
}

resource "google_compute_instance" "main" {
  for_each     = { for v in local.vm_configs : v["name"] => v }
  name         = each.value["name"]
  zone         = each.value["zone"]
  machine_type = each.value["machine_type"]
  boot_disk {
    initialize_params {
      image = "debian-os-cloud/debian-10"
      size  = 10
      type  = "pd-standard"
    }
  }
  network_interface {
    network    = google_compute_network.network.self_link
    subnetwork = google_compute_subnetwork.subnetwork.self_link
  }
  tags = ["iap"]
}

cdktf convertを使ってコードを生成してみる

CDKTFには cdktf convert というtfファイルからCDKTFコードに変換するコマンドがあります。これを使ってコードを変換します。
前述のTerraformサンプルコード(sample.tf)に対して以下のようなコマンドを実行してみます。

cat sample.tf | cdktf convert

すると、以下のような結果が出力されます。

cdktf convert出力結果
/*Provider bindings are generated by running cdktf get.
See https://cdk.tf/provider-generation for more details.*/
import * as google from "./.gen/providers/google";

/*The following providers are missing schema information and might need manual adjustments to synthesize correctly: google.
For a more precise conversion please use the --provider flag in convert.*/
const region = "asia-northeast1";
const vmConfigs = [
  {
    machine_type: "e2-medium",
    name: "bravo",
    zone: "asia-northeast1-a",
  },
  {
    machine_type: "e2-micro",
    name: "charlie",
    zone: "asia-northeast1-b",
  },
  {
    machine_type: "f1-micro",
    name: "delta",
    zone: "asia-northeast1-c",
  },
];
const googleComputeNetworkNetwork = new google.ComputeNetwork(this, "network", {
  auto_create_subnetworks: false,
  name: "cdktf-test",
  routing_mode: "REGIONAL",
});
const googleComputeSubnetworkSubnetwork = new google.ComputeSubnetwork(
  this,
  "subnetwork",
  {
    ip_cidr_range: "10.10.0.0/16",
    name: "cdktf-test",
    network: googleComputeNetworkNetwork.selfLink,
    private_ip_google_access: true,
    region: region,
  }
);
const googleComputeInstanceMain = new google.ComputeInstance(this, "main", {
  boot_disk: [
    {
      initialize_params: [
        {
          image: "debian-os-cloud/debian-10",
          size: 10,
          type: "pd-standard",
        },
      ],
    },
  ],
  machine_type: '${each.value["machine_type"]}',
  name: '${each.value["name"]}',
  network_interface: [
    {
      network: googleComputeNetworkNetwork.selfLink,
      subnetwork: googleComputeSubnetworkSubnetwork.selfLink,
    },
  ],
  tags: ["iap"],
  zone: '${each.value["zone"]}',
});

/*In most cases loops should be handled in the programming language context and
not inside of the Terraform context. If you are looping over something external, e.g. a variable or a file input
you should consider using a for loop. If you are looping over something only known to Terraform, e.g. a result of a data source
you need to keep this like it is.*/
googleComputeInstanceMain.addOverride(
  "for_each",
  `\${{ for v in ${vmConfigs} : v["name"] => v }}`
);

なんというか、これが正解コードなのか……?という気持ちになりながら、このコードをmain.tsにコピペして試してみます。
しかし、このコード、うまく動きません。よく見るとgoogle.ComputeNetworkの属性で、auto_create_subnetworksrouting_modeといったスネークケースな属性名が使われています。
これらは本来autoCreateSubnetworksroutingModeといったキャメルケースになっていなければならないはずで、誤変換されています。(他のリソースも属性名が誤っています。)
変換時に出力されたコメントをよく読むと「The following providers are missing schema information and might need manual adjustments to synthesize correctly」と書いてあるので然もありなんですね。
このあたりの属性名を修正して試して見ます。
余計なコメントを削除しつつ手修正したコードが以下になります。

        const region = "asia-northeast1";
        const vmConfigs = [
            {
                machine_type: "e2-medium",
                name: "bravo",
                zone: "asia-northeast1-a",
            },
            {
                machine_type: "e2-micro",
                name: "charlie",
                zone: "asia-northeast1-b",
            },
            {
                machine_type: "f1-micro",
                name: "delta",
                zone: "asia-northeast1-c",
            },
        ];
        const googleComputeNetworkNetwork = new google.ComputeNetwork(this, "network", {
            autoCreateSubnetworks: false,
            name: "cdktf-test",
            routingMode: "REGIONAL",
        });
        const googleComputeSubnetworkSubnetwork = new google.ComputeSubnetwork(
            this,
            "subnetwork",
            {
                ipCidrRange: "10.10.0.0/16",
                name: "cdktf-test",
                network: googleComputeNetworkNetwork.selfLink,
                privateIpGoogleAccess: true,
                region: region,
            }
        );
        const googleComputeInstanceMain = new google.ComputeInstance(this, "main", {
            bootDisk: {
                initializeParams: {
                    image: "debian-os-cloud/debian-10",
                    size: 10,
                    type: "pd-standard",
                },
            },
            machineType: '${each.value["machine_type"]}',
            name: '${each.value["name"]}',
            networkInterface: [
                {
                    network: googleComputeNetworkNetwork.selfLink,
                    subnetwork: googleComputeSubnetworkSubnetwork.selfLink,
                },
            ],
            tags: ["iap"],
            zone: '${each.value["zone"]}',
        });

        googleComputeInstanceMain.addOverride(
            "for_each",
            `\${{ for v in ${vmConfigs} : v["name"] => v }}`
        );

しかし、このコードもcdktf diff実行時にエラーになります。

エラーメッセージ
            │ Error: Missing item separator
            │
            │   on cdk.tf.json line 39, in resource.google_compute_instance.main (main):
            │   39:         "for_each": "${{ for v in [object Object],[object Object],[object Object] : v[\"name\"] => v }}",
            │
            │ Expected a comma to mark the beginning of the next item.
            ╵

メッセージから \${{ for v in ${vmConfigs} : v["name"] => v }} の部分の変換が意図していないように見えるため、 cdktf.outディレクトリにあるTerraformコードファイル(cdk.tf.json)を確認してみます。
該当部分は以下のようになっていました。

cdk.tf.json
  "resource": {
    "google_compute_instance": {
      "main": {
        ...(snip)...
        "for_each": "${{ for v in [object Object],[object Object],[object Object] : v[\"name\"] => v }}",
        ...(snip)...
      }
    },

エラーメッセージの通りですね。Typescriptのテンプレートリテラル に指定した ${vmConfigs} がそのまま文字列として変換されて cdk.tf.json に出力されてしまったようです。
ここを何とかする必要がありそうです。いろいろと試して見ましたが、vmConfigsをTypescriptのArrayではなくTerraformのLocal変数として定義し、${vmConfigs}local.vm_configsと修正することで動作しました。

該当箇所①
        new TerraformLocal(this, "vm_configs", [
            {
                machine_type: "e2-medium",
                name: "bravo",
                zone: "asia-northeast1-a",
            },
            {
                machine_type: "e2-micro",
                name: "charlie",
                zone: "asia-northeast1-b",
            },
            {
                machine_type: "f1-micro",
                name: "delta",
                zone: "asia-northeast1-c",
            },
        ]);
該当箇所②
        googleComputeInstanceMain.addOverride(
            "for_each",
            `\${{ for v in local.vm_configs : v["name"] => v }}`
        );
cdk.tf.json
  "resource": {
    "google_compute_instance": {
      "main": {
        ...(snip)...
        "for_each": "${{ for v in local.vm_configs : v[\"name\"] => v }}",
        ...(snip)...
      }
    },

ただし、ここまで読んでお気づきと思いますが、cdktf convertで得られるようなコードは、TypescriptからTerraform HCL(JSON)へどのように変換されるかを想像しながらコードを記述しなければならないため、書いていて結構つらいと思います。
特に、IDEのコード補間の恩恵を一切受けられず、cdktf synthをこまめに実行して様子をみるということをしないと正しいコードが得られないといった状況です。
一応、この方法でfor_eachは表現できそうですが、実用的かというと、ちょっといまいちな感じです。

もっとTypescriptらしくコードを書きたい

Terraformのfor_eachは、設定言語としてのHCLをプログラマブルに記述するための仕組みです。
Typescriptはそれ自体がプログラミング言語であり、プログラミング言語の機能を使用してリソースを複製するコードを書けばよいのです。
(当たり前のことをそれらしく言っているだけという)

今回のケースであればvmConfigで可変させた設定項目をArrayとして定義しているので、Arrayの各要素に対して処理を行うループ処理をかけば十分ということになります。
実はterraform-cdkリポジトリのサンプルコードにaws-prebuiltがあり、そこで今回求めているコードのサンプルがありました。

https://github.com/hashicorp/terraform-cdk/blob/715008381266c76c2b042c6723f1d83063d64d6a/examples/typescript/aws-prebuilt/main.ts#L31-L36

このコードを参考に、やりたい処理を記述してみます。

        const region = "asia-northeast1";
        const vmConfigs = [
            {
                machineType: "e2-medium",
                name: "bravo",
                zone: "asia-northeast1-a",
            },
            {
                machineType: "e2-micro",
                name: "charlie",
                zone: "asia-northeast1-b",
            },
            {
                machineType: "f1-micro",
                name: "delta",
                zone: "asia-northeast1-c",
            },
        ];

        const network = new ComputeNetwork(this, 'network', {
            name: 'cdktf-test',
            routingMode: 'REGIONAL',
            autoCreateSubnetworks: false,
        });
        const subnet = new ComputeSubnetwork(this, 'subnetwork', {
            name: 'cdktf-test',
            region: region,
            ipCidrRange: '10.10.0.0/16',
            network: network.selfLink,
            privateIpGoogleAccess: true
        });
        vmConfigs.map((i) => {
            return new ComputeInstance(this, `instance-${i.name}`, {
                name: i.name,
                zone: i.zone,
                machineType: i.machineType,
                bootDisk: {
                    initializeParams: {
                        image: 'ubuntu-os-cloud/ubuntu-2004-lts',
                        size: 10,
                        type: 'pd-standard'
                    },
                },
                networkInterface: [
                    {
                        network: network.selfLink,
                        subnetwork: subnet.selfLink,
                    }
                ],
                tags: ["iap"]
            });
        });

こんな感じで記述できました。ポイントは、リソースIDはテンプレートリテラルで可変させるようにする箇所です。
上記サンプルではインスタンス名を付与する形で instance-${i.name} と記述しています。
Typescriptのスキルが低すぎてこういう書き方ができることが分かっていませんでしたが、書いてみるとあっさり動作します

ただ、こうして記述したコードはちょっとしたデメリットがあります。CDKTFが生成するTerraformコード(cdk.tf.json)では、1つ1つ作成したようなコードになるということです。
該当部分だけ抜き出すと以下のようになっていました。(instance-bravoinstance-charlieinstance-deltaと3つのリソースをべた書きしたようなコードになります)

cdk.tf.json
    "google_compute_instance": {
      "instance-bravo": {
        "machine_type": "e2-medium",
        "name": "bravo",
        "zone": "asia-northeast1-a"
        ...(snip)...
      },
      "instance-charlie": {
        "machine_type": "e2-micro",
        "name": "charlie",
        "zone": "asia-northeast1-b"
        ...(snip)...
      },
      "instance-delta": {
        "machine_type": "f1-micro",
        "name": "delta",
        "zone": "asia-northeast1-c"
        ...(snip)...
      }
    },

CDKTFで生成したTerraformコードを流用するケースでは問題になるかも知れませんが、CDKTFのみで運用するのであればこういう動作だと割り切ってしまえば問題ないと思います。

まとめ

CDKTFでTerraformのfor_eachそのものを表現しようとすると厳しいです。
しかし、Typescript等の言語機能でループさせてリソース作成すれば問題無く定義できそうです。
今後も勉強が必要だなと思います。(特にTypescript!)

Discussion