Published on

What I'm working on: resource references in ocaml-cfgen and IAM policy generation

Authors

In my last update, I outlined a new project I'm working on called ocaml-cfgen, which generates OCaml bindings for CloudFormation resource definitions and to be able to output CloudFormation templates from OCaml code. I explained my end-goal of making it possible to use OCaml for building and deploying AWS Lambda functions and why I'm rolling my own compared to existing AWS tools like SAM or CDK.

Relating CloudFormation resources through tokens

Getting ocaml-cfgen to generate OCaml bindings against the vast collection of Cloudformation resource specifications was only one step towards being able to generate CloudFormation. As was generating yojson encoding functions, so that they could be serialised to JSON for use in a CloudFormation template.

CloudFormation resource symphony

One the next challenges was allowing resources to relate to each other through their attributes. AWS resources are quite granular, and you need quite a few to work in concert as a minimum to achieve any anything useful.

Below is an (abbreviated) example of a minimal Lambda function declaration that responds to EventBridge events:

YAML for ease of reading

Parameters:
  EventBusName:
    Type: String
    Default: default
    Description: Name of the event bus to use
  LogRetentionDays:
    Type: Integer
    Default: 90
    Description: Number days to retain CloudFormation
  Environment:
    Type: String
    Description: The name of the environment to deploy this stack in
Resources:
  # Log group needed by the function
  InvoiceSenderLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: { Fn::Sub: '/aws/lambda/invoice-sender-${Environment}' }
      RetentionInDays: !Ref LogRetentionDays
  # Function itself
  InvoiceSenderFunction:
    Type: AWS::Lambda::Function
    DependsOn: [InvoiceSenderLogGroup]
    Properties:
      Name: { Fn::Sub: 'invoice-sender-${Environment}' }
      Code: { ZipFile: ../my-function.zip }
      Role: !GetAtt MyFunctionRole.Arn
      # ...
  # Executor role that gives permissions to the lambda function
  InvoiceSenderFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicies:
        # Permits Lambda function to write to CloudWatch logs
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
  # Event Bridge rule that responds to invoice events
  InvoiceEventRule:
    Type: AWS::Events::Rule
    Properties:
      EventBusName: { Ref: EventBusName }
      EventPattern: { detail-type: [invoice-generated] }
      Targets:
        - Arn: !GetAtt InvoiceSenderFunction.Arn
  # Lambda resource policy permitting rule to invoke it
  InvoiceSenderFunctionPolicy:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: { Ref: InvoiceSenderFunction }
      Principal: events.amazonaws.com
      Source: { Ref: AWS::AccountId }
      SourceArn: { Fn::GetAtt: InvoiceEventRule.Arn }

(The technique above, where the lambda function depends on the log group and the name of both is explicit, allows us to control the generation of the log group from the CloudFormation stack instead of having it created ad-hoc from the function's first execution - there are other ways to achieve the same thing)

Apart from being quite verbose (something I'll touch on later), each resource is related to another through the use of CloudFormation intrinsic functions like Ref or Fn::GetAtt which retrieves attributes of the related resource and puts them into a field. Some values are also from the template Parameters using the Ref intrinsic as well.

The intrinsic functions are resolved at template deployment time because they return values that come from the creation of the resource (like its auto-generated resource name or its ARN, the global AWS unique naming scheme for resources).

When we generate OCaml bindings, resource properties are simply just primitive data types, like strings and integers e.g. a Lambda permission resource:

module Permission = struct
  type properties = {
    action: string;

    event_source_token: string option;

    function_name: string;

    function_url_auth_type: string option;

    principal: string;

    principal_org_i_d: string option;

    source_account: string option;

    source_arn: string option;
  }
  (* ... *)

We can't express an intrinsic function in these data types, and any resource property can be the target of a intrinsic function, so the question becomes: How do we specify an intrinsic function when all we have are strings and numbers?

Tokens - substitution in the resource serialisation layer

The answer to this question comes from AWS CDK, which uses tokens extensively to relate CloudFormation resources to each other in TypeScript and other languages.

A token is simply a special string or number that is substituted into the resource property that represents a particular piece of output JSON, in this case, for intrinsic functions.

For example, if we were specifying the properties for a Lambda permission resource to relate it back to the Lambda Function it applies to, we might have a declaration that looks like this in OCaml:

let permission_properties = {
  action = "lambda:InvokeFunction";
  function_name = "${TOKEN[Token.100]}";
  principal = "events.amazonaws.com";
  (* ... *)
}

The special string, ${TOKEN[Token.100]} is then just stored inside a global hashtable, which is looked up at serialisation time. When the string serialiser finds instances of this pattern, it looks up the hash table, and instead of generating a Yojson `String ... primitive, it substitutes for an object primitive from the hashtable instead:

our permission resource looks like this after serialisation

let serialised_permission_properties =
  `Assoc [
    (* simple properties are serialised as-is *)
    ("Action", `String "lambda:InvokeFunction");
    ("Principal", `String "events.amazonaws.com");
    (* token strings are substituted with what stored in the global token hashtable *)
    (
      "FunctionName",
      `Assoc [("Ref", `String "InvoiceFunction")] (* same as { Ref: InvoiceFunction *) *)
    );
  ]

(The numbers in the token string are simply just created on demand by incrementing a global counter - they are not fixed in any way)

Because strings are effectively of a variable length, we can include multiple tokens in the same string, which is useful in some cases, like when constructing an ARN from its parts:

let policy = {
  (* {|... |} is the equivalent of backtick string syntax in JavaScript *)
  "function_arn": {|arn:aws:lambda:${Token[Intrinsic.1]}:${Token[Intrinsic.2]}:function/${Token[TOKEN.52]}|}
}

where we have a hashtable containing these mappings:

  • ${Token[Intrinsic.1]} => { Ref: 'AWS::Region' }
  • ${Token[Intrinsic.2]} => { Ref: 'AWS::AccountId' }
  • ${Token[TOKEN.52]} => { Ref: 'InvoiceFunction' }

The string serialiser then needs to split up the string around the tokens and join it together at deployment time, which is easy enough with Fn::Join:

{
  "FunctionArn": {
    "Fn::Join": [
      "arn:aws:lambda:",
      { "Ref": "AWS::Region" },
      ":",
      { "Ref": "AWS::AccountId" },
      ":function/",
      { "Ref": "InvoiceFunction" }
    ]
  }
}

Numbers present a different challenge, as they are fixed length and represent a finite range of values. Tokens are encoded for numbers very big or very small values as tokens, with the assumption that those values will not be used for any normal resource settings. (See the source code for how ocaml-cfgen does this).

Resources attributes as token values

In the source code, when we create a new resource, we output an attributes object that contains strings and numbers with the "values" of those attributes.

let role = Stack.add_resource stack "MyRole" (module Cf_aws.IAM.Role) role_properties
...
role = {
  attributes: {
    arn = "${TOKEN[Token.30]}";
    ref = "${TOKEN[Token.31]}";
  }
}

Then, it is just a matter of assigning the values of those attributes (which are in the correct type) to the properties of other resources we want to relate together:

let bucket_parameter = Stack.add_string_parameter stack "DeploymentBucket";;

let function_properties = {
  (* ... *)
  code = { zip_file = { key = "test.zip"; bucket = bucket_parameter };
  role = role.attributes.arn;
};;

let my_function = Stack.add_resource stack "MyFunction" (module Cf_aws.Lambda.Function) function_properties;;

And from this, we can serialise a stack and print it as JSON:

let serialised_stack = Stack.serialise stack;;
Fmt.pr "%s\n" (Yojson.Safe.pretty_to_string serialised_stack);;

What's next?

Going up a level

The above gives us something of a baseline we can build upon, as we can:

  • Generate CloudFormation resource definitions and JSON serialisers in OCaml
  • Write OCaml code that describes and relates AWS resources together
  • Serialise our declarations as JSON and generate a CloudFormation template

One of the key observations about the above is that quite a lot of (tedious) work goes into the declaration of just one Lambda function, so once you multiply that against a real-world application that might have a few dozen lambda functions, plus other serverless resources (SQS queues, DynamoDB tables, API Gateway) and their connectors (Lambda event source mappings, EventBridge pipes, etc), you have hundreds and hundreds of line of repetitive declarations.

You also have the issue that there is no protection against using the wrong CloudFormation resource attribute with the incorrect property e.g. accidentally using a Fn::Ref to retrieve a resource name when the target property expects an ARN (which is usually, but not always, Fn::GetAtt: MyResource.Arn).

In CloudFormation itself, these problems are (partially) solved through the use of higher level constructs, such as the Serverless Application Model (SAM) resource types which generate much of the "glue" resources (like IAM roles, event source mappings, etc) and relate them together correctly.

For us, we can use basic OCaml abstractions, like functions and modules to group and generate much of the boilerplate that we identify as we refine our application deployment model that is described in CloudFormation.

In comparable frameworks, like CDK, these abstractions are conceptually grouped into levels of "constructs" (resources):

  • L1 constructs are the base-level CloudFormation resource types like we identified above, and which are generated directly from the CloudFormation Resource specification
  • L2 constructs are hand-written APIs that specify a primary target resource (like a Lambda function), and have helpers that generate the glue resources (event source mappings, API Gateway methods) and other auxiliary resources (IAM roles, lambda permissions, etc.) as you relate those resources to each other or specify features to turn on (e.g. provisioned concurrency, dead-letter configurations).
  • L3 constructs are groups of important resources designed to achieve an outcome or implement a particular service pattern e.g. a construct that generates an event rule and associated lambda function processor, as well as dead letter queues and CloudWatch alarms needed to support its operations. They typically link multiple primary resource types together e.g. a Lambda function with its event resource. Many L3 patterns are searchable and documented in various forms on sites like Serverless Land or Construct Hub.

My intention is to start building some of these abstractions on top of the L1 constructs that can already be generated, but this is a huge amount of work given all the resource types that exist in AWS (even AWS have missed important resources, such as API Gateway v2, in their CDK L2 constructs), so it will probably be done on an ad-hoc / as-needed basis. For consumers, the L1 resource specs are still there for anything that is missing (but they assume deep AWS knowledge).

IAM Policies and other DSLs

One of the features of the AWS resource landscape, apart from its richness and depth (there are literally hundreds of AWS services and resource types), is the numerous Domain Specific Languages (DSLs) used in a number of resource specifications.

To name a few:

In CloudFormation, the placeholder for specifying an IAM policy is simply given the type Json, which means that you either hand-write the JSON and convert it with Yojson to put it into a template, or in my case, create a simple wrapper that serialises to Yojson for use with the L1 constructs that I've generated e.g.:

let lambda_assume_role_policy_document =
  Iam_policy.(
    yojson_of_policy
      (policy
        [
           assume_role_statement
             (aws_service_principal "lambda");
        ])
    )

let lambda_cloudwatch_logs_policy =
  Iam_policy.(
    yojson_of_policy
      (policy
         [
           statement
             ~action:
               [
                 "logs:CreateLogStream";
                 "logs:CreateLogGroup";
                 "logs:PutLogEvents";
               ]
             ~resource:[ "*" ] ();
         ]))

I could imagine many more simple DSL wrappers for the various resource-specific languages that are out there.

Documentation

The CloudFormation resource specifications used to generate the base-level constructs don't contain inline documentation, but instead just contain URLs linking to the AWS user guide online. Although this is still helpful, it would be great to have inline documentation on generated constructs with ocamldoc.

AWS provide the Cloudformation reference documentation as a repository of markdown files, so assuming they can be parsed systematically, it may be possible to meld the two together.

Build time

As I covered previously, building OCaml for Lambda requires setting up a separate switch inside a Docker container running Amazon Linux 2 to ensure that the OCaml binaries are linked against the right glibc version.

Now that I can generate CloudFormation, I can link together tools which create the right switch (perhaps even using a cross-compiler), generate the dune configuration and CloudFormation based on declaration API, and assist with packaging and deployment.

By bringing the CloudFormation generation in OCaml, it becomes possible to do as much of this as possible with the same language ecosystem, which should result in a much better developer experience than wiring together Python and TypeScript tooling in a kludgy way with OCaml.