クラウドコントロールAPIをTerraformのaws providerから使ってみた

2021.10.18

2021年9月30日にクラウドコントロールAPIがGA(一般提供開始)しました。

従来、各サービスごとにバラバラだったAPIの使い方を、一貫した方法で使えるようにしたAPIです。またAWSサービスのみならず CloudFormation Registry で利用可能なサードパーティー製ソリューションもこのAPIを通して利用可能です。

さて、Terraformのaws providerも早速このクラウドコントロールAPIに対応しています。Version 3.61.0にてResourceとData Sourceがそれぞれ追加されました。

今回はこのResourceを使って、2AZにそれぞれPublicとPrivateサブネットを持つような一般的なVPC構成を作ってみたいと思います。

基本的な書き方

使うResourceは前述のaws_cloudcontrolapi_resourceのみです。このResourceのattribute type_nameに異なる値を指定することで様々なリソースをプロビジョニングすることができます。リソースの各プロパティ値はdesired_stateattributeにJSON形式で渡します。

そして、type_nameに指定する値はCloudFormation(CFn)のリソースタイプネームで、desired_state以下で指定する値もCFnで各リソースを作成するときのProperties以下の値です。

例としてまずは(サブネット等のVPC内部リソースは置いておいて、)一旦VPCだけを作成してみましょう。

desired_state以下をHCL形式で書きたい場合は以下のようにjsonencode関数で囲いましょう。

resource "aws_cloudcontrolapi_resource" "vpc" {
  type_name = "AWS::EC2::VPC"
  desired_state = jsonencode({
    CidrBlock          = "192.168.0.0/16"
    EnableDnsSupport   = true
    EnableDnsHostnames = true
    InstanceTenancy    = "default"
    Tags = [{
      Key   = "Name"
      Value = "plain-vpc"
    }]
  })
}

もしくはCFnライクにYAMLで書きたい場合は以下のような書き方もできます。

yamlで書きたい

resource "aws_cloudcontrolapi_resource" "vpc" {
  type_name = "AWS::EC2::VPC"
  desired_state = jsonencode(yamldecode(
    <<-EOT
    CidrBlock: "192.168.0.0/16"
    EnableDnsSupport: true
    EnableDnsHostnames: true
    InstanceTenancy: default
    Tags:
      - Key: Name
        Value: "plain-vpc"
    EOT
  ))
}

残りのリソース…対応してなかった

続いてInternet Gatewayを作成しようと以下コードを書いてterraform applyしました。

resource "aws_cloudcontrolapi_resource" "igw" {
  type_name = "AWS::EC2::InternetGateway"
  desired_state = jsonencode(yamldecode(
      <<-EOT
      Tags:
        - Key: Name
          Value: "basic-vpc-igw"
      EOT
  ))
}
resource "aws_cloudcontrolapi_resource" "igw_attachment" {
  type_name = "AWS::EC2::VPCGatewayAttachment"
  desired_state = jsonencode(yamldecode(
      <<-EOT
      InternetGatewayId: ${aws_cloudcontrolapi_resource.igw.id}
      VpcId: ${aws_cloudcontrolapi_resource.vpc.id}
      EOT
  ))
}

すると…エラーになりました。

╷
│ Error: error creating Cloud Control API Resource (AWS::EC2::InternetGateway): UnsupportedActionException: Resource type AWS::EC2::InternetGateway does not support CREATE action
│ 
│   with aws_cloudcontrolapi_resource.igw,
│   on vpc.tf line 17, in resource "aws_cloudcontrolapi_resource" "igw":
│   17: resource "aws_cloudcontrolapi_resource" "igw" {
│ 
╵

対応リソースを確認する方法

Internet GatewayはクラウドコントロールAPI未対応でした。どのリソースタイプが対応済なのかは以下の方法で確認可能です。

AWS CLIで

aws cloudformation describe-typeで各リソースの対応状況を確認できます。type引数にRESOURCEを、 type-nameに前述aws_cloudcontrolapi_resourceResourceのtype_nameattribute値(=CFnのリソースタイプネーム値)を指定します。

このときの返り値ProvisioningTypeの値がFULLY_MUTABLEもしくはIMMUTABLEの場合、クラウドコントロールAPIでプロビジョニングできる、つまりaws_cloudcontrolapi_resourceResourceでプロビジョニングできるようです。

VPCはFULLY_MUTABLEになっているのでプロビジョニングできます。

{
    "Arn": "arn:aws:cloudformation:ap-northeast-1::type/resource/AWS-EC2-VPC",
    "Type": "RESOURCE",
    "TypeName": "AWS::EC2::VPC",
    "IsDefaultVersion": true,
    "Description": "Resource Type definition for AWS::EC2::VPC",
    "Schema": "(長い&パースされていないので割愛)",
    "ProvisioningType": "FULLY_MUTABLE",
    "DeprecatedStatus": "LIVE",
    "Visibility": "PUBLIC",
    "TimeCreated": "2021-08-20T20:25:24.577000+00:00"
}

さきほどエラーになったInternet GatewayはNON_PROVISIONABLEですね。

{
    "Arn": "arn:aws:cloudformation:ap-northeast-1::type/resource/AWS-EC2-InternetGateway",
    "Type": "RESOURCE",
    "TypeName": "AWS::EC2::InternetGateway",
    "IsDefaultVersion": true,
    "Schema": "(長い&パースされていないので割愛)",
    "ProvisioningType": "NON_PROVISIONABLE",
    "DeprecatedStatus": "LIVE",
    "Visibility": "PUBLIC",
    "TimeCreated": "2019-11-15T15:43:30.265000+00:00"
}

ドキュメントで

以下ページにクラウドコントロールAPI対応リソースタイプ一覧がまとまっています。が、先程プロビジョニングできたVPC(AWS::EC2::VPC)が載っていないので、実態と差があるかもしれません。

残りのリソースもできる限りクラウドコントロールAPI使ってみた

さて、クラウドコントロールAPIが対応していないリソースについては、(aws_cloudcontrolapi_resourceではなく、)既存のResourceを使う方針に転換し、再度基本的なVPC構成を作成してみました。結果、クラウドコントロールAPIが使えたのは以下3種のリソースのみでした。

  • VPC
  • Route Table
  • サブネットとRoute Tableの紐付け
resource "aws_cloudcontrolapi_resource" "vpc" {
  type_name = "AWS::EC2::VPC"
  desired_state = jsonencode(yamldecode(
      <<-EOT
      CidrBlock: "192.168.0.0/16"
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: "basic-vpc"
      EOT
  ))
}

# public subnet
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_cloudcontrolapi_resource.vpc.id
  tags = {
    Name = "basic-vpc-igw"
  }
}
data "aws_availability_zones" "az" {}
resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_cloudcontrolapi_resource.vpc.id
  cidr_block              = cidrsubnet(jsondecode(aws_cloudcontrolapi_resource.vpc.desired_state).CidrBlock, 8, count.index)
  availability_zone       = data.aws_availability_zones.az.names[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "basic-vpc-public-subnet-${count.index + 1}"
  }
}
resource "aws_cloudcontrolapi_resource" "public_route_table" {
  type_name = "AWS::EC2::RouteTable"
  desired_state = jsonencode(yamldecode(
      <<-EOT
      VpcId: ${aws_cloudcontrolapi_resource.vpc.id}
      Tags:
        - Key: Name
          Value: "basic-vpc-public-subnet-rtb"
      EOT
  ))
}
resource "aws_route" "public" {
  route_table_id         = aws_cloudcontrolapi_resource.public_route_table.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}
resource "aws_cloudcontrolapi_resource" "public_subnet_route_table_association" {
  count     = 2
  type_name = "AWS::EC2::SubnetRouteTableAssociation"
  desired_state = jsonencode(yamldecode(
      <<-EOT
      SubnetId: ${aws_subnet.public.*.id[count.index]}
      RouteTableId: ${aws_cloudcontrolapi_resource.public_route_table.id}
      EOT
  ))
}

# private subnet
resource "aws_subnet" "private" {
  count                   = 2
  vpc_id                  = aws_cloudcontrolapi_resource.vpc.id
  cidr_block              = cidrsubnet(jsondecode(aws_cloudcontrolapi_resource.vpc.desired_state).CidrBlock, 8, count.index + 2)
  availability_zone       = data.aws_availability_zones.az.names[count.index]
  map_public_ip_on_launch = false
  tags = {
    Name = "basic-vpc-private-subnet-${count.index + 1}"
  }
}
resource "aws_cloudcontrolapi_resource" "private_route_table" {
  type_name = "AWS::EC2::RouteTable"
  desired_state = jsonencode(yamldecode(
      <<-EOT
      VpcId: ${aws_cloudcontrolapi_resource.vpc.id}
      Tags:
        - Key: Name
          Value: "basic-vpc-private-subnet-rtb"
      EOT
  ))
}
resource "aws_cloudcontrolapi_resource" "private_subnet_route_table_association" {
  count     = 2
  type_name = "AWS::EC2::SubnetRouteTableAssociation"
  desired_state = jsonencode(yamldecode(
     <<-EOT
      SubnetId: ${aws_subnet.private.*.id[count.index]}
      RouteTableId: ${aws_cloudcontrolapi_resource.private_route_table.id}
      EOT
  ))
}

感想

今回は敢えてやってみましたが、VPCを作成するという要件の場合はaws_cloudcontrolapi_resourceを使う必要性は薄いと思います。まだまだ非対応リソースがたくさんあるので、既存のResourceの使用に統一したほうが書きやすい・読みやすいと思います。特にaws_cloudcontrolapi_resourcedesired_stateAttribute以下の値を参照したい場合、desired_stateはJSON型になっているので一度jsondecode関数をかます必要があり、可読性を下げると思います。
(hoge = jsondecode(aws_cloudcontrolapi_resource.vpc.desired_state).CidrBlock←こういう感じです)

ユースケース

まずは、作りたいリソースがまだTerraform aws provider未対応の場合です。今後クラウドコントロールAPIは基本的に新リソースのローンチ時にそのリソースをサポートするとAWS News Blogで述べられているので、ローンチ後すぐさまTerraformでIaC化したい、という場合には活躍するでしょう。

ですが、Terraform aws providerの開発も活発なので、数日、数週待てば新リソース専用のResourceが提供されている印象があります。場合によっては事前にHashiCorp社にリリース情報が共有されていて、リリース当日にTerraformが対応している場合もあります。ですので、この「aws provider未対応のとき」というシーンは非常に限定的なものになるかもしれません。。

もうひとつ考えられるユースケースは、CFnに慣れ親しんだ開発者がTerraformを始めるとき、です。リソース単位はCFnのリソースタイプですし、desired_state以下のプロパティもまんまCFnのものなので、CFnを良くご存じの方は書きやすいかと思います。

が、!Refとか!SubとかのCFnの構文は使えないので、Terraform流に書き換える必要があります。さらに前述の通り現状aws_cloudcontrolapi_resourceで作れるリソースは非常に限定的なので、今すぐ始めるのはしんどそうですね。またそもそもですが、せっかくTerraform始めるんだったら、普通にaws_cloudcontrolapi_resource使わずに書けるように勉強したほうが良いとも思います。すみません、このユースケースもちょっと苦しいですかね。。今後のアップデートでさらに便利になっていくのを期待したいと思います。