CDK is a great framework by AWS that allows you to define cloud infrastructure
as code (IaC). You can use your favourite programming language such as
TypeScript, Python, Java, Golang to define your resources.
This feature is particularly convenient as it automates the generation of
CloudFormation templates in a readable and more manageable way.
However, not every AWS resource can be mapped directly to a CloudFormation
template using CDK. In my particular case I had to create secure SSM parameters
from within CDK. Typically this is how you create a SSM parameter in
CloudFormation:
Code Snippet 1:
Sample CloudFormation template for creating SSM parameters
In ❶ you specify the type of the SSM parameter:
standard (String)
simple key-value string pair
does not support versioning
advanced (StringList)
key-value pairs with additional metadata
does support versioning
secure string (SecureString)
similar to standard parameters but the data is encrypted at rest using AWS KMS
this is used for storing sensitive data such as passwords, API keys and other credentials
Depending on the stack action, CloudFormation sends your function a Create,
Update, or Delete event. Because each event is handled differently, make sure
that there are no unintended behaviors when any of the three event types is
received. – Source
Custom resources can be used in an AWS CloudFormation stack to create, update,
delete some resources that are not available as a native CFN (CloudFormation)
resource. This could be SSL certificates that need to be generated in a certain
way, custom DNS records or anything outside AWS. The Lambda function will take
care of the lifecycle management of that specific resource.
In CDK you would create your custom resource which has a so called provider
attached (in our case it’s a Lambda function) meant to implement the logic
whenever the resource is created, updated or deleted. After cdk synth a new
CloudFormation template for the CDK stack is created. Whenever a resource is
created/updated/deleted a new CloudFormation event will occur. This event will
be sent to the Lambda function which eventually will create/update/delete SSM
parameters based on the event’s properties.
This gives you enough flexibility to define what should happen when certain
events occur. Let’s dig into deeper into the specifics.
Of course you can jump right away to the Github repository:
AWS Lambda
Basic template
As mentioned before the custom resource should be baked by an AWS Lambda function. This is how you would write the basic function structure:
packagemainimport("context""encoding/json""fmt""github.com/aws/aws-lambda-go/cfn")// Global AWS session variable
varawsSessionaws.Config// ❶
// init will setup the AWS session
funcinit(){// ❷
cfg,err:=config.LoadDefaultConfig(context.TODO(),config.WithRegion("eu-central-1"))iferr!=nil{log.Fatalf("unable to load SDK config, %v",err)}awsSession=cfg}// lambdaHandler handles incoming CloudFormation events
// and is of type cfn.CustomResourceFunction
funclambdaHandler(ctxcontext.Context,eventcfn.Event)(string,map[string]interface{},error){varphysicalResourceIDstringresponseData:=map[string]interface{}{}switchevent.ResourceType{// ❹
case"AWS::CloudFormation:CustomResource":customResourceHandler:=NewSSMCustomResourceHandler(awsSession)returncustomResourceHandler.HandleEvent(ctx,event)default:return"",nil,fmt.Errorf("Unknown resource type: %s",event.ResourceType)}returnphysicalResourceID,nil,nil}// main function
funcmain(){// From : https://github.com/aws/aws-lambda-go/blob/main/cfn/wrap.go
//
// LambdaWrap returns a CustomResourceLambdaFunction which is something lambda.Start()
// will understand. The purpose of doing this is so that Response Handling boiler
// plate is taken away from the customer and it makes writing a Custom Resource
// simpler.
//
// func myLambda(ctx context.Context, event cfn.Event) (physicalResourceID string, data map[string]interface{}, err error) {
// physicalResourceID = "arn:...."
// return
// }
//
// func main() {
// lambda.Start(cfn.LambdaWrap(myLambda))
// }
lambda.Start(cfn.LambdaWrap(lambdaHandler))// ➌
}
Code Snippet 2:
Basic structure of the AWS Lambda function in Go
Some explanations:
The main function will call a lambda handler ➌
Before main gets executed the init function will be executed first ❷
it will try to connect to AWS and populate the global variable defined at ❶
within lambdaHandler we also have to make sure check for the right CFN custom resource type ❹
Custom resource handler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// handleSSMCustomResource decides what to do in case of CloudFormation event
func(sSSMCustomResourceHandler)HandleSSMCustomResource(ctxcontext.Context,eventcfn.Event)(string,map[string]interface{},error){switchevent.RequestType{// ❶
casecfn.RequestCreate:returns.Create(ctx,event)casecfn.RequestUpdate:returns.Update(ctx,event)casecfn.RequestDelete:returns.Delete(ctx,event)default:return"",nil,fmt.Errorf("Unknown request type: %s",event.RequestType)}}
Code Snippet 1:
The main handler of the Lambda function
Supposing we use a custom type called SSMCustomResourceHandler we can have a main entrypoint (in this example called HandleSSMCustomResource) where we call a different method depending on the events request type ❶.
Each method will apply trigger different sorts of operations. This is what will happen whenever a new custom resource is created:
// Create creates a new SSM parameter
func(sSSMCustomResourceHandler)Create(ctxcontext.Context,eventcfn.Event)(string,map[string]interface{},error){varphysicalResourceIDstring// Get custom resource parameter from event
ssmPath,err:=strProperty(event,"key")// ❶
iferr!=nil{returnphysicalResourceID,nil,fmt.Errorf("Couldn't extract credential's key: %s",err)}physicalResourceID=ssmPath// ❷
ssmValue,err:=strProperty(event,"value")// ❶
iferr!=nil{returnphysicalResourceID,nil,fmt.Errorf("Couldn't extract credential's value: %s",err)}// Put new parameter ➌
_,err=s.ssmClient.PutParameter(context.Background(),&ssm.PutParameterInput{Name:aws.String(ssmPath),Value:aws.String(ssmValue),Type:types.ParameterTypeSecureString,Overwrite:aws.Bool(true),})log.Printf("Put parameter into SSM: %s",physicalResourceID)iferr!=nil{returnphysicalResourceID,nil,fmt.Errorf("Couldn't put parameter (%s): %s\n",ssmPath,err)}returnphysicalResourceID,nil,nil}
Code Snippet 1:
Create method of the SSMCustomResourceHandler
Create should create a new SSM parameter (of type SecureString) based on the information contained within the CloudFormation
event. In ❶ I use a helper function to extract a property out of the event. Once we have the ssmPath we also set the physicalResourceID to that value ❷. Afterwards we will call PutParameter which should create a new SSM parameter.
The CloudFormation event contains much information. This is what it looks like:
Now that we know how to deal with CloudFormation events and how to manage the custom resource, let’s deep-dive into DevOps and setup a small CDK application. Usually I would write the CDK part in Python but for this project I’ve setup my very first CDK application in TypeScript 😏. Let’s start with the basic template.
Deployment Stack
The deployment stack I’ve defined which resources/components should be created:
import*ascdkfrom"aws-cdk-lib";import*aspathfrom"path";import*ascustomResourcesfrom"aws-cdk-lib/custom-resources";import*aslambdafrom"aws-cdk-lib/aws-lambda";import*asiamfrom'aws-cdk-lib/aws-iam';import{spawnSync,SpawnSyncOptions}from"child_process";import{Construct}from"constructs";import{SSMCredential}from"./custom-resource";exportclassDeploymentsStackextendscdk.Stack{// ❶
constructor(scope: Construct,id: string,props?: cdk.StackProps){super(scope,id,props);// Build the Golang based Lambda function
constlambdaPath=path.join(__dirname,"../../");// Create IAM role
constiamRole=newiam.Role(this,'Role',{...});// ❷
// Add further policies to IAM role
iamRole.addToPolicy(...);// ➌
// Create Lambda function
constlambdaFunc=newlambda.Function(this,"GolangCustomResources",{...});// ❹
// Create a new custom resource provider
constprovider=newcustomResources.Provider(this,"Provider",{...});// ❺
// Create custom resource
newSSMCredential(this,"SSMCredential1",provider,{...});// ❻
}}
Code Snippet 1:
deployments-stack.ts
So my CDK application will:
create a new CloudFormation stack called DeploymentsStack ❶
create a new IAM role ❷
used to attach it to the lambda function
here we define the IAM policies required to operate on SSM parameters
add several IAM policies to the IAM role ➌
create a new AWS Lambda function ❹
create a so called provider ❺ which is responsible for the lifecycle management of the custom resources in AWS
in our case this is our lambda function
I’m not sure if this can be something different 😕
Custom resource
In the previous section I’ve mentioned SSMCredential which is our new custom resource to implement a SSM parameter of type SecureString.
import{SSMCredential}from"./custom-resource";...exportclassDeploymentsStackextendscdk.Stack{constructor(scope: Construct,id: string,props?: cdk.StackProps){super(scope,id,props);...// Create a new custom resource provider
constprovider=newcustomResources.Provider(this,"Provider",{onEventHandler: lambdaFunc,});// Create custom resource
newSSMCredential(this,"SSMCredential1",provider,{key:"/test/testing",value:"some-secret-value",});}}
Code Snippet 1:
How to use a SSMCredential in your CDK stack
Screenshots
As pictures say more than words, let’s have a look at some screenshots to better
understand what’s happening under the hood. Doing so you might get a better
understanding of the workflow and all the involved components that are created
by CDK.
// SSMParameterAPI defines an interface for the SSM API calls
// I use this interface in order to be able to mock out the SSM client and implement unit tests properly.
//
// Also check https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/gov2/ssm
typeSSMParameterAPIinterface{DeleteParameter(ctxcontext.Context,params*ssm.DeleteParameterInput,optFns...func(*ssm.Options))(*ssm.DeleteParameterOutput,error)PutParameter(ctxcontext.Context,params*ssm.PutParameterInput,optFns...func(*ssm.Options))(*ssm.PutParameterOutput,error)}typeSSMCustomResourceHandlerstruct{ssmClientSSMParameterAPI}
I use my own interface for the SSM parameter API as this can be easily mocked out when writing unit tests:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SSMParameterApiImpl is a mock for SSMParameterAPI
typeSSMParameterApiImplstruct{}// PutParameter
func(sSSMParameterApiImpl)PutParameter(ctxcontext.Context,params*ssm.PutParameterInput,optFns...func(*ssm.Options))(*ssm.PutParameterOutput,error){output:=&ssm.PutParameterOutput{}returnoutput,nil}// DeleteParameter
func(sSSMParameterApiImpl)DeleteParameter(ctxcontext.Context,params*ssm.DeleteParameterInput,optFns...func(*ssm.Options))(*ssm.DeleteParameterOutput,error){output:=&ssm.DeleteParameterOutput{}returnoutput,nil}
Code Snippet 1:
In aws_custom_resource_test.go
Now I can use the SSMParameterApiImpl as a mocked client as it satisfies the SSMParameterAPI interface:
$ sam local invoke -t cdk.out/CustomResourcesGolang.template.json GolangCustomResources -e ../tests/create.json
Invoking /main (go1.x)Local image is up-to-date
Using local image: public.ecr.aws/lambda/go:1-rapid-x86_64.
Mounting /home/victor/work/repos/aws-custom-resource-golang/deployments/cdk.out/asset.1ac1b002ba7d09e11c31702e1724d092e837796c2ed40541947abdfc6eb75947 as /var/task:ro,delegated, inside runt
ime container
START RequestId: cb8c7882-269c-434e-ace1-f6958940ee2e Version: $LATEST2023/03/31 11:48:16 Starting lambda
2023/03/31 11:48:16 event: cfn.Event{RequestType:"Create", RequestID:"9bf90339-c6f0-47ff-ad67-e19226facf6e", ResponseURL:"https://some-file", ResourceType:"AWS::CloudFormation::CustomResour
ce", PhysicalResourceID:"", LogicalResourceID:"SSMCredential21D358858", StackID:"arn:aws:cloudformation:eu-central-1:xxxxxxxxxxxx:stack/CustomResourcesGolang/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxx", ResourceProperties:map[string]interface {}{"ServiceToken":"arn:aws:lambda:eu-central-1:xxxxxxxxxxxx:function:CustomResourcesGolang-ProviderframeworkonEvent83C1-Dt9Jv3RwL9KT", "key":
"/test/testing12345", "value":"some-secret-value"}, OldResourceProperties:map[string]interface {}{}}2023/03/31 11:48:16 Creating SSM parameter
2023/03/31 11:48:16 Put parameter into SSM: /test/testing12345
Put "https://some-file": dial tcp: lookup some-file on 192.168.179.1:53: no such host: Error
null
END RequestId: cb8c7882-269c-434e-ace1-f6958940ee2e
REPORT RequestId: cb8c7882-269c-434e-ace1-f6958940ee2e Init Duration: 0.13 ms Duration: 327.24 ms Billed Duration: 328 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"errorMessage":"Put \"https://some-file\": dial tcp: lookup some-file on 192.168.179.1:53: no such host","errorType":"Error"}%
This one fails coz I didn’t specify any valid ResponseURL.
Conclusion
I think this approach opens a lot of possibilities to create advanced custom resources based on your needs. You could for example use custom resources to deploy resources across multiple accounts. For Security reasons you could enforce several compliance policies and monitor for compliance deviations. Or you could use some 3rd-party APIs to pass data back and forth (e.g. user management, product stocks etc.)
As you have control over the logic implemented in the AWS Lambda function and therefore define how your custom resources should be managed, the possibilities are endless. Have fun creating your own custom resources!