🚆

[AWS] Terraform で CloudFormation のカスタムリソースと同等のことを実現する

2023/12/08に公開

ログラスのクラウドエンジニアの原です。

AWS のリソースをコードで記述して対応するリソースを構築できる CloudFormation のリソースには、カスタムリソースと呼ばれるものがあります。
この記事では、カスタムリソースが何者であるかを解説するとともに、それと同等のことを Terraform で実現する方法を紹介します。

CloudFormation のカスタムリソースとは?

CloudFormation のカスタムリソースは、そのリソースが作成・更新・削除されるときに指定した Lambda 関数を実行して、何らかの操作ができるというものです。

使い道としては、私には次のようなものが思い浮かびます、

  • CloudFormation や AWS CDK の L2 コンストラクタでは設定ができないが、AWS SDK や API からは設定できることを設定
    • CDK の L2 コンストラクタは、そのリソースの必要となる IAM などのリソースを明示的に指定しなくても構築してくれるなど、リソースをより抽象化していて便利なことがある反面、CloudFormation のリソースに対応する L1 コンストラクタで設定できるすべての項目に対応しているとは限りません。そのため、L2 コンストラクタを使いつつも L2 コンストラクタではできないことをカスタムリソースで補うということはよく行われます。
      • たとえば、RDS の DatabaseCluster の L1 コンストラクタ CfnDBCluster には、associatedRoles という属性があり、任意の IAM Role を関連づけることができますが、L2 コンストラクタ DatabaseCluster には対応する属性がありません。
        • L2 コンストラクタのコードを見ると、L1 コンストラクタの associatedRoles には s3ImportRoles3ExportRole で指定した IAM Role だけが渡され、ユーザーが指定した IAM Role を渡すインターフェースがないことが分かります。
  • AWS リソースを構築するのと同時に、RDS や DynamoDB のようなデータストア、S3 のようなストレージにデータやファイルを書き込む
    • 当方での使い方の例(当方では CloudFormation のカスタムリソースではなく、本記事のテーマにあるように Terraform で実現しています。)
      • テナントごとに必要なリソースを構築するときに、そのテナントの情報を DynamoDB に書き込む。
      • AWS Glue データカタログのデータベースに新しいテーブルとそのテーブルにロードする Glue ジョブを作成する際に、対応する Glue ジョブのスクリプトをテンプレートから生成する。
    • カスタムリソースの削除の際の Lambda 関数の動作にデータストアやストレージからの削除を実装しておけば、AWS リソースとともにこのカスタムリソースを削除した際にデータストアやストレージからデータやファイルを削除することができて、構築前の状態に完全に戻すことができます。
  • AWS CDK で L2 コンストラクタの機能を拡張する
    • AWS CDK の L2 コンストラクタでもカスタムリソースは活用されており、CloudFormation では対応できないことを拡張として実装しています。
      • 例えば、S3 のバケットの削除をする際、そのバケットのオブジェクトをすべて削除したあとでないとバケットを削除できないことは多くの方が経験されているのではないかと思います。CDK の S3 Bucket の L2 コンストラクタ には autoDeleteObjects という属性があり、これを true に設定してバケットを作成すると、スタックからそのバケットのリソースの記述が削除されて、それに対応してそのバケットが削除される際に、そのバケットに存在しているオブジェクトをすべて自動で削除してくれます。
        • CloudFormation や AWS CDK の L1 コンストラクタにはそのような機能はありません。
        • 実は、この挙動はカスタムリソースを用いて実現されています(カスタムリソースLambda 関数)。

CloudFormation のカスタムリソースの記述方法

次の YAML は CloudFormation のテンプレートでカスタムリソースを記述する例です。以下、AWS のアカウント ID などはマスクしています。

Resources:
  CustomResource:
    Type: AWS::CloudFormation::CustomResource
    Properties:
      ServiceToken: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:print_event
      greeting: Hello
  • TypeAWS::CloudFormation::CustomResource を指定します。
  • Properties には
    • ServiceToken に実行する Lambda 関数の ARN を指定します。
    • その他のキーと値は、Lambda 関数の入力になります。具体的には、Lambda 関数の入力ペイロードの ResourceProperties にそのまま渡されます。

Lambda に渡される event を見てみる

Lambda 関数に渡される event を print するだけの簡単な Lambda 関数をカスタムリソースの ServiceToken に指定した CloudFormation のテンプレートを作成して、そのテンプレートからスタックを作成してみます。

Lambda 関数(Python で記述)のコードは次のようなものになります。

import json

def lambda_handler(event, context):
    print(json.dumps(event))
    return {
        'statusCode': 200,
        'body': 'OK'
    }

上に示した CloudFormation のテンプレートの ServiceToken に上のコードから作成した Lambda 関数の ARN を指定して、スタックを作成すると、CloudWatch Logs に出力されるこの Lambda 関数のログには、実際に Lambda 関数が実行されて次のような event が print されているのが確認できます。

{
   "RequestType":"Create",
   "ServiceToken":"arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:print_event",
   "ResponseURL":"https://cloudformation-custom-resource-response-apnortheast1.s3-ap-northeast-1.amazonaws.com/arn%3Aaws%3Acloudformation%3Aap-northeast-1%3AXXXXXXXXXXXX%3Astack/CustomResourceStack/c767dc20-8c57-11ee-874c-0e44606502b1%7CCustomResource%7Cacfff0a8-b0e2-46fc-a38c-07f1be57a4a0?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20231126T123203Z&X-Amz-SignedHeaders=host&X-Amz-Expires=7200&X-Amz-Credential=AKIASU3QWAIZOA2CTNX3%2F20231126%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Signature=5ad9aa78f0f2c47b5997ddec079872e864d59ee12aa02170a05afa5545a0fceb",
   "StackId":"arn:aws:cloudformation:ap-northeast-1:XXXXXXXXXXXX:stack/CustomResourceStack/c767dc20-8c57-11ee-874c-0e44606502b1",
   "RequestId":"acfff0a8-b0e2-46fc-a38c-07f1be57a4a0",
   "LogicalResourceId":"CustomResource",
   "ResourceType":"AWS::CloudFormation::CustomResource",
   "ResourceProperties":{
      "ServiceToken":"arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:print_event",
      "greeting": "Hello"
   }
}

このリクエストオブジェクトの仕様についてはAWS のドキュメントに説明がありますが、ポイントは次の点です。

  • 上の例では、RequestTypeCreate になっていますが、このリソースが更新されるとき(Properties の値が変更されてスタックの更新がされるとき)には Update、このリソースが削除されるときには Delete になります。RequestType の値に応じて、リソースの作成時、更新時、削除時の挙動を実装します。
  • ResourceProperties にはテンプレートの Properties に指定されたキーと値がそのまま出力されます。

なお、上の Lambda 関数のコードを使って CloudFormation のスタックを作成しようとすると、ログが出力されて Lambda 関数が実行されていることは確認できますが、しばらくの間(1時間くらい)「作成中」の状態を継続したのちに、スタックの作成が失敗します。
あとで説明するように、カスタムリソースから実行される Lambda 関数には、成功や失敗の情報を上の Lambda イベントの中にある ResponseURL に送信する必要があります。その情報の受信を持って、CloudFormation が Lambda 関数の処理の成否を判断するのですが、上の Lambda 関数のコードにはその処理がなかったために、CloudFormation が Lambda 関数からの情報を待つ状態になり、最終的にタイムアウトして失敗したものです。

カスタムリソースで起動される Lambda 関数の例

どのようなイベントが Lambda 関数に入力されるかがわかったところで、カスタムリソースに利用できる簡単な Lambda 関数を書いてみます。

import cfnresponse

def lambda_handler(event, context):
    try:
        action = event["RequestType"]
        properties = event["ResourceProperties"]
        
        if action == "Create":
            print(f"Create: {properties['greeting']}")
        elif action == "Update":
            print(f"Update: {properties['greeting']}")
        elif action == "Delete":
            print(f"Delete: {properties['greeting']}")
        else:
            raise Exception(f"Unknown Action: {action}")
    
        cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
    except Exception:
        cfnresponse.send(event, context, cfnresponse.FAILED, {})

    return {
        'statusCode': 200,
        'body': "OK"
    }

すでに述べたように、event の RequestType の値によって、リソースの作成・更新・削除それぞれに対応した処理を記述します。
この例では、RequestTypeが取り得る値それぞれの場合に、 RequestTypegreeting それぞれの値を print しています。

また、cfnresponse というモジュールを import しています。このモジュールのソースコードは、AWS のドキュメントに掲載されていますので、そのコードをそのまま cfnresponse.py というファイル名で、上の Lambda 関数本体と同じ階層に配置しておきます。

カスタムリソースを含むスタックの作成、更新、削除

作成

Lambda 関数の準備ができたところで、上の Lambda 関数を使うように設定したカスタムリソースを含むスタックを作成してみます。
上の Lambda 関数には custom_resource_test という名前を付けています。

Resources:
  CustomResource:
    Type: AWS::CloudFormation::CustomResource
    Properties:
      ServiceToken: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:custom_resource_test
      greeting: Hello

このテンプレートからスタックを作成すると、スタックの作成が成功して、この Lambda 関数のログには

Create: Hello

という出力が記録されます。リソースが作成時に実行するように実装したことが意図通りに実行されていることが確認できます。

更新

次に、カスタムリソースの属性を更新して、スタックを更新してみます。
テンプレートの greeting を下のように変更します。

Resources:
  CustomResource:
    Type: AWS::CloudFormation::CustomResource
    Properties:
      ServiceToken: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:custom_resource_test
      greeting: Bye

そして、スタックを更新すると、Lambda 関数のログに次が出力されていることが確認できます。

Update: Bye

これは、リソースの更新時に実行するように実装した内容になっています。

削除

最後にリソースを削除してみましょう。
スタックを削除することで、リソースを削除してみます。
そうすると、Lambda 関数のログに

Delete: Bye

が出力され、リソースの削除時に実行するように実装した挙動になっています。

(おまけ)AWS CDK のコード

ここまでで、CloudFormation のカスタムリソースの挙動を理解することができました。
ちなみに、AWS CDK (TypeScript) では、次のようなコードで、上とテンプレートと同じリソースの記述ができます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda'

export class CustomResourceStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const lambda_function = lambda.Function.fromFunctionAttributes(this, "LambdaCustomResourceTest", {
      functionArn: "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:custom_resource_test",
      skipPermissions: true
    });
    new cdk.CustomResource(this, 'CustomResource', {
      serviceToken: lambda_function.functionArn,
      properties: {
        greeting: "Hello",
      }
    });
  }
}

Terraform で CloudFormation のカスタムリソースと同等のことを実現する

次に、Terraform で CloudFormation のカスタムリソースと同等のことを実現する方法を考えます。

Terraform の AWS Provider のリソースの一つに lambda_invocationという、Lambda 関数を実行するリソースがあります。
デフォルトでは、このリソースが作成される terraform apply の実行時にのみ Lambda 関数が実行されますが、最近リリースされた AWS Provider のバージョン 5 では、lifecycle_scope = "CRUD" という属性を指定することで、Lambda 関数にリソースの作成、更新、削除のいずれに対応するかを伝えられるようになりました[1]
Lambda 関数では、その情報を受け取って、それぞれの挙動を実装することになります。CloudFormation のカスタムリソースで、 RequestType の値(Create/Update/Delete)によってそれぞれの値に対応する挙動を実装したのと同じになります。

lambda_invocation の使い方

aws_lambda_invocation のリソースを使うためには、Lambda 関数名、入力を指定します。
そして、CloudFormation のカスタムリソースと同等の挙動を得るためには、lifecycle_scope = "CRUD"を指定しておくことがポイントです。

resource "aws_lambda_invocation" "exmaple" {
    function_name = "print_event"
    input = jsonencode({
        "resource_properties" = {
            "greeting" = "Hello"
        }
    })
    lifecycle_scope = "CRUD"
}

このコードでは、CloudFormation のカスタムリソースのときにも使った event を print する Lambda 関数を指定しています。
また、入力の JSON のスキーマは任意のものが指定できますが、CloudFormation のカスタムリソースの場合には、ResourcePropertiesというブロックの中に個別のパラメータを指定していたのに合わせて、ここでも同じ構造にしています。
(その結果、Lambda 関数の構造がほぼ同じになります)

Lambda 関数に入力されるイベント

上のコードを用いて、CloudFormation のカスタムリソースと同様に、まずは Lambda 関数にどのような入力がくるのかを確認しておきます。
terraform apply を実行すると、Lambda 関数のログに次のような出力がされていました。

{
    "resource_properties": {
        "greeting": "Hello"
    },
    "tf": {
        "action": "create",
        "prev_input": null
    }
}

lambda_invocation のドキュメントにも説明があるように、自分でコードで指定した入力値に加え、tf というブロックが追加され、そのブロックの action に作成(create)、更新(update)、削除(delete) のいずれのイベントに対応するものなのかの情報が連携されます。
この値を使って、リソースの作成、更新、削除それぞれのときの挙動を Lambda 関数の中で実装できます。

lambda_invocation で用いる Lambda 関数の例

CloudFormation のカスタムリソースで用いた Lambda 関数と同じ挙動となる Lambda 関数の一つの例です。
以下では、この Lambda 関数を名前を tf_lambda_invocation_test としています。

def lambda_handler(event, context):
    action = event["tf"]["action"]
    properties = event["resource_properties"]

    if action == "create":
        print(f"Create: {properties['greeting']}")
    elif action == "update":
        print(f"Update: {properties['greeting']}")
    elif action == "delete":
        print(f"Delete: {properties['greeting']}")
    else:
        raise Exception(f"Unknown Action: {action}")
    
    return {
        'statusCode': 200,
        'body': "OK"
    }

Terraform の場合は、cfnresponse に対応する処理はありません。Lambda 関数の成功・失敗を Terraform がモニターしているためです。

Terraform のコード

改めて、上の Lambda 関数を用いる lambda_invocation のリソースのコードは次のようになります。

resource "aws_lambda_invocation" "exmaple" {
    function_name = "tf_lambda_invocation_test"
    input = jsonencode({
        "resource_properties" = {
            "greeting" = "Hello"
        }
    })
    lifecycle_scope = "CRUD"
}

lambda_invocation のリソースの作成、更新、削除

作成

上のコードを terraform apply してみます。

$ terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
  + create

Terraform will perform the following actions:

  # aws_lambda_invocation.exmaple will be created
  + resource "aws_lambda_invocation" "exmaple" {
      + function_name   = "tf_lambda_invocation_test"
      + id              = (known after apply)
      + input           = jsonencode(
            {
              + resource_properties = {
                  + greeting = "Hello"
                }
            }
        )
      + lifecycle_scope = "CRUD"
      + qualifier       = "$LATEST"
      + result          = (known after apply)
      + terraform_key   = "tf"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_lambda_invocation.exmaple: Creating...
aws_lambda_invocation.exmaple: Creation complete after 0s [id=tf_lambda_invocation_test_$LATEST_410cd67c2e8e1518e41125f49784e0a2]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

terraform apply によって、aws_lambda_invocation.exmaple というリソースが作成されます。
そして、Lambda 関数 tf_lambda_invocation_test のログには

Create: Hello

と出力されており、リソースを作成するときに期待する挙動をしていることを確認できます。

更新

次に、input を変更して、リソースを変更してみます。

resource "aws_lambda_invocation" "exmaple" {
    function_name = "tf_lambda_invocation_test"
    input = jsonencode({
        "resource_properties" = {
            "greeting" = "Bye"
        }
    })
    lifecycle_scope = "CRUD"
}

このコードで terraform apply を実行してみます。

$ terraform apply
aws_lambda_invocation.exmaple: Refreshing state... [id=tf_lambda_invocation_test_$LATEST_410cd67c2e8e1518e41125f49784e0a2]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_lambda_invocation.exmaple will be updated in-place
  ~ resource "aws_lambda_invocation" "exmaple" {
        id              = "tf_lambda_invocation_test_$LATEST_410cd67c2e8e1518e41125f49784e0a2"
      ~ input           = jsonencode(
          ~ {
              ~ resource_properties = {
                  ~ greeting = "Hello" -> "Bye"
                }
            }
        )
        # (5 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_lambda_invocation.exmaple: Modifying... [id=tf_lambda_invocation_test_$LATEST_410cd67c2e8e1518e41125f49784e0a2]
aws_lambda_invocation.exmaple: Modifications complete after 0s [id=tf_lambda_invocation_test_$LATEST_43324e45507f0cf202c5ea41cc5d9f5c]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

inputgreeting の値の更新によって、aws_lambda_invocation.exmapleというリソースの更新をしています。
そして、Lambda 関数のログには

Update: Bye

と出力され、更新時に期待される挙動になっています。

削除

最後にリソースの削除をしてみます。
コード全体をコメントアウトして、terraform apply を実行します。

$ terraform apply
aws_lambda_invocation.exmaple: Refreshing state... [id=tf_lambda_invocation_test_$LATEST_43324e45507f0cf202c5ea41cc5d9f5c]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
  - destroy

Terraform will perform the following actions:

  # aws_lambda_invocation.exmaple will be destroyed
  # (because aws_lambda_invocation.exmaple is not in configuration)
  - resource "aws_lambda_invocation" "exmaple" {
      - function_name   = "tf_lambda_invocation_test" -> null
      - id              = "tf_lambda_invocation_test_$LATEST_43324e45507f0cf202c5ea41cc5d9f5c" -> null
      - input           = jsonencode(
            {
              - resource_properties = {
                  - greeting = "Bye"
                }
            }
        ) -> null
      - lifecycle_scope = "CRUD" -> null
      - qualifier       = "$LATEST" -> null
      - result          = jsonencode(
            {
              - body       = "OK"
              - statusCode = 200
            }
        ) -> null
      - terraform_key   = "tf" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_lambda_invocation.exmaple: Destroying... [id=tf_lambda_invocation_test_$LATEST_43324e45507f0cf202c5ea41cc5d9f5c]
aws_lambda_invocation.exmaple: Destruction complete after 0s

Apply complete! Resources: 0 added, 0 changed, 1 destroyed.

aws_lambda_invocation.exmapleというリソースが削除されました。
そして、Lambda 関数のログには次のように出力されていました。

Delete: Bye

削除のときも、期待した挙動になっています。

まとめ

CloudFormation のカスタムリソースと同等のことを Terraform でも実現できる方法を紹介しました。
Terraform で対応していないリソースの設定や操作(AWS 外のリソースの操作も含む)をはじめとして、クラウドインフラのリソースに付随するレコードの追加、更新、削除などで便利に使える機能です。

おまけ

この記事では、馴染みの深い方が多いと思われる Python を使って Lambda 関数を書きました。
私は普段は Rust で Lambda 関数を書いています。
上の Python で記述した Lambda 関数と同等のものを Rust で書いてみました。
トレイトを活用して、CloudFormation のカスタムリソース、Terraform の lambda_invocation のどちらのイベントにも一つの Lambda 関数で対応できるようにしてみました(通常は、どちらか一方だけを使うので、両方に対応したのはナンセンスなのですが、お遊びでやってみました)。
そのコードをここに載せておきます。

  • CloudFormation のカスタムリソース、Terraform の lambda_invocation それぞれの入力に対して、動作することは確認してあります。
  • おまけで作ったものなので、最適なコードとは限りません。たとえば、
    • モジュールに分割したほうがよいですが、掲載の便宜上、一つのファイルにまとめてあります。
    • エラー処理が十分でない場合があります。
Rust による Lambda 関数の実装
src/main.rs
use aws_lambda_events::event::cloudformation::CloudFormationCustomResourceRequest;
use lambda_runtime::{run, service_fn, Error, LambdaEvent};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Serialize)]
struct Response {
    req_id: String,
    msg: String,
}

#[derive(Deserialize, Clone)]
enum Action {
    #[serde(rename(deserialize = "create"))]
    Create,
    #[serde(rename(deserialize = "update"))]
    Update,
    #[serde(rename(deserialize = "delete"))]
    Delete,
}

#[derive(Deserialize)]
struct TFLambdaInvokeRequest {
    resource_properties: ResourceProperties,
    tf: TFLambdaInvokeInjection,
}

#[derive(Deserialize)]
struct TFLambdaInvokeInjection {
    action: Action,
}

#[derive(Deserialize)]
struct ResourceProperties {
    greeting: String,
}

#[derive(Deserialize)]
struct ResourcePropertiesCFn {
    action: Action,
    stack_id: String,
    request_id: String,
    logical_resource_id: String,
    response_url: String,
    resource_properties: ResourceProperties,
}

#[derive(Deserialize)]
struct ResourcePropertiesTF {
    action: Action,
    resource_properties: ResourceProperties,
}

impl TryFrom<CloudFormationCustomResourceRequest> for ResourcePropertiesCFn {
    type Error = Error;
    fn try_from(request: CloudFormationCustomResourceRequest) -> Result<Self, Self::Error> {
        let (action, value, stack_id, request_id, logical_resource_id, response_url) = match request
        {
            CloudFormationCustomResourceRequest::Create(request) => (
                Action::Create,
                request.resource_properties,
                request.stack_id,
                request.request_id,
                request.logical_resource_id,
                request.response_url,
            ),
            CloudFormationCustomResourceRequest::Update(request) => (
                Action::Update,
                request.resource_properties,
                request.stack_id,
                request.request_id,
                request.logical_resource_id,
                request.response_url,
            ),
            CloudFormationCustomResourceRequest::Delete(request) => (
                Action::Delete,
                request.resource_properties,
                request.stack_id,
                request.request_id,
                request.logical_resource_id,
                request.response_url,
            ),
        };
        let ret = Self {
            action,
            stack_id,
            request_id,
            logical_resource_id,
            response_url,
            resource_properties: serde_json::from_value::<ResourceProperties>(value)?,
        };
        Ok(ret)
    }
}

impl TryFrom<TFLambdaInvokeRequest> for ResourcePropertiesTF {
    type Error = Error;
    fn try_from(request: TFLambdaInvokeRequest) -> Result<Self, Self::Error> {
        Ok(Self {
            action: request.tf.action,
            resource_properties: request.resource_properties,
        })
    }
}

impl TryFrom<Value> for ResourcePropertiesCFn {
    type Error = Error;
    fn try_from(value: Value) -> Result<Self, Self::Error> {
        serde_json::from_value::<CloudFormationCustomResourceRequest>(value)?.try_into()
    }
}

impl TryFrom<Value> for ResourcePropertiesTF {
    type Error = Error;
    fn try_from(value: Value) -> Result<Self, Self::Error> {
        serde_json::from_value::<TFLambdaInvokeRequest>(value)?.try_into()
    }
}

#[async_trait]
trait HandleEvent {
    async fn on_create(&self) -> Result<(), Error>;
    async fn on_update(&self) -> Result<(), Error>;
    async fn on_delete(&self) -> Result<(), Error>;
    fn action(&self) -> Action;
    fn resource_properties(&self) -> &ResourceProperties;
}

#[async_trait]
impl HandleEvent for ResourcePropertiesCFn {
    fn action(&self) -> Action {
        self.action.clone()
    }
    fn resource_properties(&self) -> &ResourceProperties {
        &self.resource_properties
    }
    async fn on_create(&self) -> Result<(), Error> {
        self.send_cfnresponse_handler(self.resource_properties.on_create().await)
            .await
    }
    async fn on_update(&self) -> Result<(), Error> {
        self.send_cfnresponse_handler(self.resource_properties.on_update().await)
            .await
    }
    async fn on_delete(&self) -> Result<(), Error> {
        self.send_cfnresponse_handler(self.resource_properties.on_delete().await)
            .await
    }
}

#[async_trait]
impl HandleEvent for ResourcePropertiesTF {
    fn action(&self) -> Action {
        self.action.clone()
    }
    fn resource_properties(&self) -> &ResourceProperties {
        &self.resource_properties
    }
    async fn on_create(&self) -> Result<(), Error> {
        self.resource_properties.on_create().await
    }
    async fn on_update(&self) -> Result<(), Error> {
        self.resource_properties.on_update().await
    }
    async fn on_delete(&self) -> Result<(), Error> {
        self.resource_properties.on_delete().await
    }
}

#[derive(Serialize)]
enum CFnResponseStatus {
    #[serde(rename(serialize = "SUCCESS"))]
    Success,
    #[serde(rename(serialize = "FAILED"))]
    Failed,
}

struct SendCFnResponseArgs<'a> {
    response_status: &'a CFnResponseStatus,
    stack_id: &'a str,
    request_id: &'a str,
    logical_resource_id: &'a str,
    response_url: &'a str,
    response_data: &'a Value,
    reason: &'a str,
    physical_resource_id: &'a str,
}

async fn send_cfnresponse(args: &SendCFnResponseArgs<'_>) -> Result<(), Error> {
    let response_body = json!({
        "Status" : args.response_status,
        "Reason": args.reason,
        "StackId" : args.stack_id,
        "RequestId" : args.request_id,
        "LogicalResourceId" : args.logical_resource_id,
        "PhysicalResourceId": args.physical_resource_id,
        "Data" : args.response_data
    });
    let output = reqwest::Client::new()
        .put(args.response_url)
        .json(&response_body)
        .send()
        .await?;
    let status = output.status().as_u16();
    println!("Status: {}", status);
    if (200..300).contains(&status) {
        Ok(())
    } else {
        Err(format!("Error: {}", output.text().await?).into())
    }
}

impl ResourcePropertiesCFn {
    async fn send_cfnresponse_handler(&self, result: Result<(), Error>) -> Result<(), Error> {
        let physical_resource_id = "custom-resource-physical-id";
        match result {
            Ok(_) => {
                let args = SendCFnResponseArgs {
                    response_status: &CFnResponseStatus::Success,
                    stack_id: &self.stack_id,
                    request_id: &self.request_id,
                    logical_resource_id: &self.logical_resource_id,
                    response_url: &self.response_url,
                    response_data: &json!({}),
                    reason: "OK",
                    physical_resource_id,
                };
                send_cfnresponse(&args).await
            }
            Err(e) => {
                tracing::error!("Error: {}", e);
                let reason = format!("{}", e);
                let args = SendCFnResponseArgs {
                    response_status: &CFnResponseStatus::Failed,
                    stack_id: &self.stack_id,
                    request_id: &self.request_id,
                    logical_resource_id: &self.logical_resource_id,
                    response_url: &self.response_url,
                    response_data: &json!({}),
                    reason: &reason,
                    physical_resource_id,
                };
                send_cfnresponse(&args).await
            }
        }
    }
}

impl ResourceProperties {
    async fn on_create(&self) -> Result<(), Error> {
        println!("Create: {}", self.greeting);
        Ok(())
    }
    async fn on_update(&self) -> Result<(), Error> {
        println!("Update: {}", self.greeting);
        Ok(())
    }
    async fn on_delete(&self) -> Result<(), Error> {
        println!("Delete: {}", self.greeting);
        Ok(())
    }
}

async fn handler<T: HandleEvent>(resource_properties: &T) -> Result<(), Error> {
    match resource_properties.action() {
        Action::Create => resource_properties.on_create().await,
        Action::Update => resource_properties.on_update().await,
        Action::Delete => resource_properties.on_delete().await,
    }
}

async fn function_handler(event: LambdaEvent<Value>) -> Result<Response, Error> {
    if let Ok(resource_properties_cfn) = ResourcePropertiesCFn::try_from(event.payload.clone()) {
        handler(&resource_properties_cfn).await?;
    } else if let Ok(resource_properties_tf) = ResourcePropertiesTF::try_from(event.payload) {
        handler(&resource_properties_tf).await?;
    } else {
        tracing::error!("Unexpected Format");
        return Err("Unexpected Format".into());
    }

    let resp = Response {
        req_id: event.context.request_id,
        msg: "Ok".to_string(),
    };
    Ok(resp)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .with_target(false)
        .without_time()
        .init();

    run(service_fn(function_handler)).await
}
Cargo.toml
[package]
name = "cr_test"
version = "0.1.0"
edition = "2023"

[dependencies]
async-trait = "0.1.74"
aws-config = "1.0.1"
aws_lambda_events = "0.12.1"

lambda_runtime = "0.8.3"
openssl = { version = "0.10.60", features = ["vendored"] }
reqwest = { version = "0.11.22", features=["json"] }
serde = "1.0.136"
serde_json = "1.0.108"
tera = "1.19.1"
tokio = { version = "1", features = ["macros"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] }

明日のアドベントカレンダー

ログラスのCTO、坂本さんによる記事です。お楽しみに!

脚注
  1. https://github.com/hashicorp/terraform-provider-aws/pull/29367 ↩︎

株式会社ログラス テックブログ

Discussion