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
You will need:
and optionally:
pip install --global --upgrade awscli cfn-lint
It is assumed you have a reasonable working knowledge of basic AWS resources like IAM, Lambda as well as deployment with CloudFormation.
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)
Create a new directory called lambdadef
with the following files
dune
(executable
(name lambdadef)
(libraries yojson cfgen)
)
dune-project
(lang dune 3.5)
(generate_opam_files true)
(package
(name lambdadef)
(allow_empty)
(depends cfgen yojson)
)
lambdadef.ml
open Cfgen
let template = Template.make ();;
Format.printf "%s\n" (template |> Template.serialise |> Yojson.Safe.pretty_to_string)
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
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).
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.
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"]
()
)
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 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
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" } }
],
}
}
...
Lastly, we'll create a stack output to share the name of the lambda function with other stacks via an export.
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" }
}
}
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"
}
],
...
}
]