Creating a CloudFormation stack with cfgen

This tutorial takes you through the basics needed to use cfgen to create a CloudFormation template with OCaml, showing you the main functionality of the library.

NOTE: These instructions have only been verified on Linux

Prerequisites

You will need:

and optionally:

It is assumed you have a reasonable working knowledge of basic AWS resources like IAM, Lambda as well as deployment with CloudFormation.

Set up your switch

Install cfgen into your switch along with some depedencies e.g.

opam install yojson merlin ocaml-lsp dune
opam pin cfgen https://github.com/chris-armstrong/ocaml-cfgen.git#v1.0.0-alpha.0

(merlin, dune, ocaml-lsp are for your editor and build system, yojson for JSON serialisation)

Set up a new project

Create a new directory called lambdadef with the following files

Validate your project can build in your shell:

dune build

And if you execute the code, you should see a minimal CloudFormation template

dune exec ./lambdadef.exe

Writing code with cfgen in your editor

When working in your editor, run dune build --watch in a shell in the background.

This is needed for code completion and compile error reporting. Your project will need to have built successfully at least once for these to work (so comment out any broken code when you start to get completion/error reporting back, save, then start uncommenting it again).

Add a resource

All AWS resource types have been auto-generated into the Cfgen.BaseConstructs.AWS module.

Let's define a NodeJS based lambda function with some inline code.

Create an IAM Role

First we'll define an IAM role.

lambdadef.ml

(* after let template = Template.make() *)

(* A trust policy tells IAM what services (or other "Principals") can
   assume the role
*)
let trust_policy = Helpers.Iam_policy.(
  policy [
    assume_role_statement (aws_service_principal "lambda")
  ]
  |> yojson_of_policy
)

(* Add a role to the template *)
let hello_world_role = Template.add_resource
  template
  "HelloWorldRole"
  (module AWS.IAM.Role)
  AWS.IAM.Role.(
    make_properties
      ~assume_role_policy_document: trust_policy
      (* Use the builtin managed policy that lets the lambda write CloudWatch logs *)
      ~managed_policy_arns: ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"]
     ()
  )

Add the lambda function

lambdadef.ml

(* after role declaration *)

let hello_world_code = {|
const handler = async (event) => {
  const response = `Hello ${event.name ?? "there"}!`;
  return { response };
};
module.exports.handler = handler;
|};;

let _ = Template.add_resource
  template
  "HelloWorldFunction"
  (module AWS.Lambda.Function)
  AWS.Lambda.Function.(make_properties
    ~code: (make_code ~zip_file:hello_world_code ())
    ~runtime: "nodejs18.x"
    ~handler: "index.handler"
    ~role: hello_world_role.attributes.arn
    ()
  )

Notice that our lambda function can reference the role directly through its attributes - there is no need to use static resource names in your template.

Compile and re-execute (dune exec will rebuild the code):

dune exec ./lambdadef.exe

(optional) Lint and deploy the template

optional If we wanted to validate and deploy the template, save it to a file:

dune exec ./lambdadef.exe > test_stack.json

validate it with cfn-lint,

cfn-lint test_stack.json

and deploy it with AWS CLI.

aws cloudformation deploy \
  --stack-name lambdadef \
  --template-file ./test_stack.json \
  --capabilities CAPABILITY_IAM

Once it is deployed, find its name and invoke it directly from the AWS CLI:

> aws cloudformation describe-stack-resource --stack-name lambdadef --logical-resource-id HelloWorldFunction

< { .. <json output> } # copy the PhysicalResourceId - this is the function name e.g. lambdadef-HelloWorldFunction-98DGP58DVzfz

> aws lambda invoke --function-name <function_name> test_out
<{
<    "StatusCode": 200,
<    "ExecutedVersion": "$LATEST"
<}
> cat test_out
< {"response":"Hello there!"}
> rm test_out

Add a template parameter

Let's use a parameter to provide the name of the environment, and use it to generate an Environment tag for tracking the resources in the stack against a particular environment.

Add the following code after the template object creation:

let environment = Template.add_string_parameter
  template
  "Environment"
  ~default_value: "production"
  ~allowed_values: ["production"; "staging"; "development"]
  ()

Then, for both the role and lambda resource, add the following line to its make_properties call to register a tag.

  ~managed_policy_arns: ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"]
  ~tags: [make_tag ~key: "Environment" ~value: environment.ref_ ()]
  ()
)
~handler: "index.handler"
~role: hello_world_role.attributes.arn
~tags: [make_tag ~key: "Environment" ~value: environment.ref_ ()]
()

In the output, we can see the parameter, and the reference in the generated tag:

{
  "Parameters": {
    "Environment": {
      "Type": "String",
      "AllowedValues": [ "production", "staging", "development" ],
      "Default": "production"
    }
  },
  "Resources": {
    "HelloWorldFunction": {
      "Type": "AWS::Lambda::Function",
      "Properties": {
        "Tags": [
          { "Value": { "Ref": "Environment" }, "Key": "Environment" }
        ],
        "Runtime": "nodejs18.x",
        ...
      }
    },
    "HelloWorldRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
         "Tags": [
           { "Key": "Environment", "Value": { "Ref": "Environment" } }
         ],
      }
    }
    ...

Add a stack output

Lastly, we'll create a stack output to share the name of the lambda function with other stacks via an export.

Export a value from the stack as an output

Change the declaration of the function to specify a variable name

i.e.

let hello_world_function = Template.add_resource
  template
  "HelloWorldFunction"
  (module AWS.Lambda.Function)

and then after the function declaration, add the output referencing it:

Template.add_output
  template
  "HelloWorldFunctionName"
  hello_world_function.attributes.ref_
  ~export:(Intrinsics.stack_name ^ "-hello-world-function-name")
  ()

The export has been added using an intrinsic from Intrinsics, which generate tokens referencing values only available at deployment time

when you re-run the template declaration, the references to the lambda function and export are added in the Outputs section of the template:

"Outputs": {
  "HelloWorldFunctionName": {
    "Export": {
      "Name": {
        "Fn::Join": [
          "", [{ "Ref": "AWS::StackName" }, "-hello-world-function-name" ]
        ]
      }
    },
    "Value": { "Ref": "HelloWorldFunction" }
  }
}

(optional) Checking the output value

If you generate and deploy the stack again, you can check out the generated value.

> dune exec ./lambdadef.exe > ./test_stack.json
> aws cloudformation deploy \
  --stack-name lambdadef \
  --template-file ./test_stack.json \
  --parameter-overrides Environment=development
  --capabilities CAPABILITY_IAM
> aws cloudformation describe-stacks --stack-name lambdadef
<                                                                                                                                                                     [0/4513]
    "Stacks": [
        {
            ...
            "StackName": "lambdadef",
            "Parameters": [
                {
                    "ParameterKey": "Environment",
                    "ParameterValue": "development"
                }
            ],
            "CreationTime": "2023-04-10T11:58:46.004000+00:00",
            "LastUpdatedTime": "2023-04-13T11:00:58.510000+00:00",
            "RollbackConfiguration": {},
            "StackStatus": "UPDATE_COMPLETE",
            "DisableRollback": false,
            "NotificationARNs": [],
            "Capabilities": [
                "CAPABILITY_IAM"
            ],
            "Outputs": [
                {
                    "OutputKey": "HelloWorldFunctionName",
                    "OutputValue": "lambdadef-HelloWorldFunction-98DGP58DVzfz",
                    "ExportName": "lambdadef-hello-world-function-name"
                }
            ],
            ...
        }
    ]