[AWS] Terraform で CloudFormation のカスタムリソースと同等のことを実現する
ログラスのクラウドエンジニアの原です。
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
には s3ImportRole や s3ExportRole で指定した IAM Role だけが渡され、ユーザーが指定した IAM Role を渡すインターフェースがないことが分かります。
- L2 コンストラクタのコードを見ると、L1 コンストラクタの
- たとえば、RDS の DatabaseCluster の L1 コンストラクタ CfnDBCluster には、associatedRoles という属性があり、任意の IAM Role を関連づけることができますが、L2 コンストラクタ DatabaseCluster には対応する属性がありません。
- CDK の L2 コンストラクタは、そのリソースの必要となる IAM などのリソースを明示的に指定しなくても構築してくれるなど、リソースをより抽象化していて便利なことがある反面、CloudFormation のリソースに対応する L1 コンストラクタで設定できるすべての項目に対応しているとは限りません。そのため、L2 コンストラクタを使いつつも L2 コンストラクタではできないことをカスタムリソースで補うということはよく行われます。
- AWS リソースを構築するのと同時に、RDS や DynamoDB のようなデータストア、S3 のようなストレージにデータやファイルを書き込む
- 当方での使い方の例(当方では CloudFormation のカスタムリソースではなく、本記事のテーマにあるように Terraform で実現しています。)
- テナントごとに必要なリソースを構築するときに、そのテナントの情報を DynamoDB に書き込む。
- AWS Glue データカタログのデータベースに新しいテーブルとそのテーブルにロードする Glue ジョブを作成する際に、対応する Glue ジョブのスクリプトをテンプレートから生成する。
- カスタムリソースの削除の際の Lambda 関数の動作にデータストアやストレージからの削除を実装しておけば、AWS リソースとともにこのカスタムリソースを削除した際にデータストアやストレージからデータやファイルを削除することができて、構築前の状態に完全に戻すことができます。
- 当方での使い方の例(当方では CloudFormation のカスタムリソースではなく、本記事のテーマにあるように Terraform で実現しています。)
- AWS CDK で L2 コンストラクタの機能を拡張する
- AWS CDK の L2 コンストラクタでもカスタムリソースは活用されており、CloudFormation では対応できないことを拡張として実装しています。
- 例えば、S3 のバケットの削除をする際、そのバケットのオブジェクトをすべて削除したあとでないとバケットを削除できないことは多くの方が経験されているのではないかと思います。CDK の S3 Bucket の L2 コンストラクタ には autoDeleteObjects という属性があり、これを
true
に設定してバケットを作成すると、スタックからそのバケットのリソースの記述が削除されて、それに対応してそのバケットが削除される際に、そのバケットに存在しているオブジェクトをすべて自動で削除してくれます。
- 例えば、S3 のバケットの削除をする際、そのバケットのオブジェクトをすべて削除したあとでないとバケットを削除できないことは多くの方が経験されているのではないかと思います。CDK の S3 Bucket の L2 コンストラクタ には autoDeleteObjects という属性があり、これを
- AWS CDK の L2 コンストラクタでもカスタムリソースは活用されており、CloudFormation では対応できないことを拡張として実装しています。
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
-
Type
にAWS::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 のドキュメントに説明がありますが、ポイントは次の点です。
- 上の例では、
RequestType
がCreate
になっていますが、このリソースが更新されるとき(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
が取り得る値それぞれの場合に、 RequestType
と greeting
それぞれの値を 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.
input
の greeting
の値の更新によって、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 関数の実装
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
}
[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、坂本さんによる記事です。お楽しみに!
Discussion