AWS CDK: Custom Resource
Custom Resources is a great way to extend the possibility of CloudFormation. We can use to create, update, delete custom resources, resolving cyclic dependencies in our stack, and many more!
In this article, we will first be taking a brief look at what we can do with Custom Resources and the general flow of using it.
We will then check out two different approaches in defining Custom Resource in our CDK Stack. I will also be sharing with you a simple Lambda handler to handle the events (Custom resource request) sent from CloudFormation.
General Introduction
A quick overview on AWS CloudFormation Custom Resource so that we know what we actually need to create!
First of all, Custom resources provide a way for us to add custom provisioning logic into the CloudFormation templates and have CloudFormation run it anytime you create, update (if you changed the custom resource), or delete a stack.
Here are some of the scenarios you might want to check it out.
- Resources you want to create are not available as CloudFormation resource types (yet…)
- Add (Extend) a custom action, for example, empty an S3 bucket before trying to delete it (which would fail)
- Resolve cyclic reference errors between resources. (ME HERE…) This happens quite often to me where I would want a resource to trigger a lambda function but I also want to pass in the resource ARN to the lambda as an environment variable so that I can verify the event before processing on.
Now, how does the custom Resource work (in a super high level) or what’s the flow?
- CloudFormation invokes the custom resource on stack Create, Update, and Delete
- If you have a registry based resources, depending on what the resource is, you might be able to end here
- Otherwise, we handle the request, do whatever action we need, and return a response. To return the response, we can either upload it to the pre-signed S3 URL (that we will get in the request) by ourselves, or we can use a Provider wrapper to have it handle for us.
It might look really abstract for right now but I promise it will become a lot more clear after looking at some code!
CDK
There are two different ways in defining Custom Resource depending on what you need.
- With Provider and CustomResource
- With AwsCustomResource for registry based resources
We will be using Lambda here as an example. Let’s check it out one by one!
Provider + Custom Resource
The best part about it is we can include an entire resource as part of a template without registering. However, we will have to use an SNS topic or write our own Lambda function to actual handle the request.
CDK Code
import { Construct } from "constructs";
import {
Duration,
Stack,
StackProps,
custom_resources as cr,
CustomResource
} from "aws-cdk-lib";
import { RustFunction } from 'cargo-lambda-cdk';
import { join } from 'path';
export class CustomResourceStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
const handlerLambda = new RustFunction(this, 'custom-resource-lambda', {
manifestPath: join(__dirname, '..', '..', 'backend/cdk_custom_resource_handler/'),
environment: {},
timeout: Duration.minutes(5),
});
const provider = new cr.Provider(this, "custom-resource-provider", {
onEventHandler: handlerLambda,
});
const customResource = new CustomResource(this, "custom-resource", {
serviceToken: provider.serviceToken,
properties: {
"value1": "1",
"value2": "2"
}
});
}
}Couple notes I would like to make here before we move on.
First of all, the serviceToken.
This parameter specifies where CloudFormation sends requests to, such as an Amazon SNS topic or a Lambda function.
For the value, we can either use the serviceToken from the provider like what we had above, OR, we can always directly use a Lambda Function or SNS Topic by passing the ARN into serviceToken.
However, do note that it is not recommended to use AWS Lambda Functions directly and there are many benefits of using the Provider!
- Handles responses to AWS CloudFormation and protects against blocked deployments
- Validates handler return values to help with correct handler implementation
- Supports asynchronous handlers to enable operations that require a long waiting period for a resource, which can exceed the AWS Lambda timeout, by specifying an additional AWS Lambda function in
isCompleteHandler. - Implements default behavior for physical resource IDs.
There are also some other properties we can use to configure the Provider such as vpc, securityGroups, and subnets. These can be really useful if your lambda needs to sit within a VPC to access, for example, RDS, so make sure to give the document a check!
Second, properties.
Get Itsuki’s stories in your inbox
Join Medium for free to get updates from this writer.
It is basically just a list key-value pairs that you would like to pass in to the lambda function and will be the value that shows up in the ResourceProperties field of the request object sent to lambda as we will see next!
Lambda Handler
We receive a Custom resource request objects
{
"RequestType" : "Create",
"ResponseURL" : "http://pre-signed-S3-url-for-response",
"StackId" : "arn:aws:cloudformation:...",
"RequestId" : "<RequestId>",
"ResourceType" : "AWS::CloudFormation::CustomResource",
"LogicalResourceId" : "...",
"ResourceProperties" : {
"value1": "1",
"value2": "2"
}
}and return a Custom resource response objects.
{
"Status" : "SUCCESS",
"PhysicalResourceId" : "TestResource1",
"StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/mystack/5b918d10-cd98-11ea-90d5-0a9cd3354c10",
"RequestId" : "unique id for this create request",
"LogicalResourceId" : "MyTestResource",
"NoEcho": false,
"Data" : {
"OutputName1" : "Value1",
"OutputName2" : "Value2",
}
}NOTE!
If the name-value pairs in the Data contain sensitive information, we should set the NoEcho field to true to mask the output. Otherwise, the values are visible through APIs such as DescribeStackEvents.
Here is an example on how to do it in Rust. I am simply printing out the request here.
use std::collections::HashMap;
use aws_lambda_events::cloudformation::{
CloudFormationCustomResourceRequest, CloudFormationCustomResourceResponse,
CloudFormationCustomResourceResponseStatus,
};
use lambda_runtime::{
service_fn,
tracing::{self},
Error, LambdaEvent,
};
use lib::service::cognito::CognitoService;
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing::init_default_subscriber();
let config = aws_config::load_from_env().await;
let cognito_service = CognitoService::new(&config);
let service_function =
service_fn(|event| async { cognito_handler(event, &cognito_service).await });
lambda_runtime::run(service_function).await?;
Ok(())
}
async fn cognito_handler(
event: LambdaEvent<CloudFormationCustomResourceRequest>,
service: &CognitoService,
) -> Result<CloudFormationCustomResourceResponse, Error> {
let request = event.payload;
let request_id: String;
let stack_id: String;
let logical_resource_id: String;
match request {
CloudFormationCustomResourceRequest::Create(create_request) => {
println!("create request: {:?}", create_request);
request_id = create_request.request_id;
stack_id = create_request.stack_id;
logical_resource_id = create_request.logical_resource_id
}
CloudFormationCustomResourceRequest::Update(update_request) => {
println!("update_request: {:?}", update_request);
request_id = update_request.request_id;
stack_id = update_request.stack_id;
logical_resource_id = update_request.logical_resource_id
}
CloudFormationCustomResourceRequest::Delete(delete_request) => {
println!("delete_request: {:?}", delete_request);
request_id = delete_request.request_id;
stack_id = delete_request.stack_id;
logical_resource_id = delete_request.logical_resource_id
}
};
let response = CloudFormationCustomResourceResponse {
status: CloudFormationCustomResourceResponseStatus::Success,
reason: None,
physical_resource_id: "some_id".to_owned(),
stack_id: stack_id,
request_id: request_id,
logical_resource_id: logical_resource_id,
no_echo: false,
data: HashMap::new(),
};
return Ok(response);
}I strongly recommend not to return (throw) an error from the lambda but instead set the Status to ERROR and provide a reason!
Physical Resource ID
Pretty much all other properties we set in the response are just echos from the request except for this one and it can be super important based on your usage of the custom resource!
I write some_id on purpose! Because this should be based on what you need or the behavior you want!
This value must be a non-empty string and must be identical for all responses for the same resource.
What should we set it to be or How should we set it?
Here is an example of how other non-custom resource defines the value.
- Normally, it should be the service token that can be retrieved, for example, using
create_request.service_token. In our example, this will be the lambda ARN. - On update operations, if the value returned is the same, it is considered a normal update. If the value returned is different, AWS CloudFormation recognizes the update as a replacement and sends a delete request to the old resource.
- Depending on the processing of the Lambda function, we might want to set this to be the resource key (ID, ARN, whatever you need to call SDK’s API) instead of simply using the lambda ARN. What do I meant by that? For example, if we are creating a new S3 Bucket in our Lambda function, we might want to set it to be the bucket name so that we can put or delete objects and bucket.
AwsCustomResource
Unlike the approach above, by using registry based resources, we will not need to associate an Amazon SNS topic or a Lambda function to perform the Create, Read, Update, Delete, and List(CRUDL) operations.
export class CustomResourceStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
const userPoolId = props.congitoUserPoolId;
const userPoolClientId = props.congitoClientId;
const handlerLambda = new RustFunction(this, 'security-checklist-custom-resource-lambda', {
manifestPath: join(__dirname, '..', '..', 'backend/cdk_custom_resource_handler/'),
environment: {},
timeout: Duration.minutes(5),
});
const invokeContent: cr.AwsSdkCall = {
action: 'Invoke',
service: 'Lambda',
parameters: {
FunctionName: handlerLambda.functionName,
InvocationType: 'Event',
Payload: JSON.stringify({
"value1": "1"
}),
},
physicalResourceId: cr.PhysicalResourceId.of(handlerLambda.functionArn)
};
const resource = new cr.AwsCustomResource(this, `registry-based-resource`, {
installLatestAwsSdk: true,
onCreate: invokeContent,
onUpdate: invokeContent,
onDelete: invokeContent,
});
handlerLambda.grantInvoke(resource)
}
}In the case of lambda, it might look pretty stupid, but this is actually the easier approach if the processing in the Lambda function is a single API Action, for example, for S3 putObject, we can simply define our invokeContent without the need to create a whole Lambda for it.
{
service: 'S3',
action: 'putObject',
parameters: {
Bucket: "some_bucket_name,
Key: 'file.txt',
Body: 'Hello, world!',
},
physicalResourceId: PhysicalResourceId.of('file-txt'),
}Caution
CloudFormation handles those calls using a singleton Lambda function.
What does that mean?
Even if we call new cr.AwsCustomResource twice, only one lambda function will be created. Which means if we have multiple, S3 for example, that the CloudFormation needs to perform actions on, it needs permission to ALL.
If this is the case, instead of using grantInvoke or grant_xxx, you should create a role assumed By lambda.amazonaws.com with the necessary permissions and attach it to the AwsCustomResource with the role parameter.
const customResourceFunctionRole = new iam.Role(this, `CustomResourceFunctionRole`, {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
inlinePolicies: {
lambdaPolicy: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
actions: ['s3:*'],
resources: [//....resources],
}),
],
}),
},
roleName: `CustomResourceFunctionRole`
});
const resource = new cr.AwsCustomResource(this, `registry-based-resource`, {
// ...
role: customResourceFunctionRole
});A Little Extra
Somewhat obvious, but if there is not change in the stack, the Update action will not be called and nothing will be sent to our lambda.
If we want to force the call, all we have to do is to add an additional field that generate something unique every time, for example, current time.
// CustomResource
properties: {
"time": new Date()
}
// cr.AwsCustomResource
Payload: JSON.stringify({ datetime: Date.now() })Thank you for reading!
That’s it for this article!
Happy Customizing!










