Generate yourself some Terraform code from TOML

In this post, we will see how we can use Golang to generate Terraform configuration from a TOML specification. That is, given a TOML file, like:

subnet_name = "SubnetA"

rules = [
    {rule_no=101, egress = false, protocol = "tcp", rule_action = "allow", cidr_block = "127.0.0.1/32", from_port = 22, to_port = 30},   
]

We will generate:

# This is a generated file, do not hand edit. See README at the
# root of the repository

resource "aws_network_acl_rule" "rule_SubnetA_ingress_101" {

    network_acl_id = "${lookup(local.network_acl_ids_map, "SubnetA")}"
    egress = false
    rule_number = 101
    rule_action = "allow"
    cidr_block = "127.0.0.1/32"
    protocol = "tcp"
    from_port = 22
    to_port = 30


}

We will specifically be using AWS Network ACL rules as an example, but the solution for the problem discussed is likely extrpolable to other cloud resources.

Background on count

Using count is a popular approach to creating multiple instances of the same resource. I have been combining it with lists and maps to configure multiple instances of resources such as AWS VPC subnets, Autoscaling groups and most recently Network ACL rules.

For example:


module "vpc_services" {
  source   = "../../modules/vpc"
  ...
  
  private_subnet_nacl_rules = "${list(
    map(
      "subnet_name", "SubnetA",
      "rule_number", 100,
      "egress", false,
      "protocol", "tcp",
      "rule_action", "allow",
      "cidr_block","${local.vpc_root}.12.0/24",
      "from_port", 1433,
      "to_port", 1433
    ),
    map(
      "name", "SubnetB",
      "rule_number", 101,
      "egress", true,
      "protocol", "tcp",
      "rule_action", "allow",
      "cidr_block","${local.vpc_root}.93.0/24",
      "from_port", 32768,
      "to_port", 65535
    ),
    ...
    # more such rules
  )}"

}

The resource creation looks as follows:

locals {
    public_network_acl_ids_map = "${zipmap(
        aws_subnet.public.*.tags.Name, aws_network_acl.public_subnets.*.id
    )}",
    private_network_acl_ids_map = "${zipmap(
        aws_subnet.private.*.tags.Name, aws_network_acl.private_subnets.*.id
    )}"
}

...

resource "aws_network_acl_rule" "private_subnet_rules" {
    count = "${length(var.private_subnet_nacl_rules)}"

    network_acl_id = "${lookup(
        local.private_network_acl_ids_map,
        lookup(var.private_subnet_nacl_rules[count.index], "subnet_name")
    )}"

    rule_number    = "${lookup(var.private_subnet_nacl_rules[count.index], "rule_number")}"
    egress         = "${lookup(var.private_subnet_nacl_rules[count.index], "egress")}"
    protocol       = "${lookup(var.private_subnet_nacl_rules[count.index], "protocol")}"
    rule_action    = "${lookup(var.private_subnet_nacl_rules[count.index], "rule_action")}"

    cidr_block     = "${lookup(var.private_subnet_nacl_rules[count.index], "cidr_block")}"
    from_port      = "${lookup(var.private_subnet_nacl_rules[count.index], "from_port")}"
    to_port        = "${lookup(var.private_subnet_nacl_rules[count.index], "to_port")}"
}

Since we are using the count attribute which Terraform uses in its state to keep track of the resources’ state, a change in an item somewhere in the middle of the private_subnet_nacl_rules list, will in this case cause the rules following itto be created and destroyed. Of course, this is not limited to Network ACL rules. See issue.

What do we do? The most straightforward approach to this is to create separate aws_network_acl_rule resources by hand. Instead of writing by hand however, what if we generate the ACL rules? That way:

Specification for Network ACL rules

An AWS network ACL rule has the following specification:

I propose a toml based specification:

subnet_name = "SubnetA"

rules = [
    {rule_no=101, egress = false, protocol = "tcp", rule_action = "allow", cidr_block = "127.0.0.1/32", from_port = 22, to_port = 30},
    {rule_no=102, egress = true, protocol = "tcp", rule_action = "allow", cidr_block = "127.0.0.1/32", from_port = 22, to_port = 30}
]

The assumption here is that, we will have a Network ACL rules specification file per Network ACL and the network ACL ID will be derived from the Subnet’s name specified in subnet_name.

Generating Terraform configuration

Now that we have a specification for our network acl rules, we will now write our program which will generate Terraform code from it. I will be using burntsushi/toml to parse the TOML file and serialize it into a Golang structure.

The key bit here is the Golang struct which we will serialize the rules into:

type naclRulesSpec struct {
	SubnetName string     `toml:"subnet_name"`
	Rules      []naclRule `toml:"rules"`
}

We define naclRule as a struct as follows:

type naclRule struct {
	NetworkACLID string `tf:"network_acl_id"`
	Egress       bool   `toml:"egress" tf:"egress" tf_type:"bool"`
	RuleNo       int64  `toml:"rule_no" tf:"rule_number" tf_type:"int"`
	RuleAction   string `toml:"rule_action" tf:"rule_action"`
	CidrBlock    string `toml:"cidr_block" tf:"cidr_block"`
	Protocol     string `toml:"protocol" tf:"protocol"`
	FromPort     int64  `toml:"from_port" tf:"from_port" tf_type:"int"`
	ToPort       int64  `toml:"to_port" tf:"to_port" tf_type:"int"`
}

From the rules specification above, you can see that we are not specifying the network acl ID, since in this case we will be generating Terraform code to look it up based on the subnet name. For all the other fields, we specify the struct tag toml:xxx corresponding to the TOML table key we specify in the rules specification. The other struct tags we specify, tf and tf_type are used in generating the Terraform code:

The following code will then read a Network ACL rules specification and serialize it into Golang objects:

naclSpecPath := os.Args[1]
var naclRules naclRulesSpec
if _, err := toml.DecodeFile(naclSpecPath, &naclRules); err != nil {
    fmt.Println("Error", err)
    return
}
subnetName = naclRules.SubnetName

At this stage, we have all the network ACL rules in naclRules.Rules. Let’s say we would want to run some validation on the rules specified - is the rule number valid? Is the CIDR a valid CIDR? and any other custom criteria we can think of. We can do so before we generate the Terraform code. It’s also worth noting that the above serialization step will also assist in catching data type mismatch errors.

Here’s how we can run validation on the specified rules and generate Terraform code if all the rules are valid:

// We use the index only pattern here so that
// we can modify the array elements to insert the
// static value for NetworkAclID
for i := range naclRules.Rules {
	if result, err := naclRules.Rules[i].Validate(); !result {
		log.Fatalf("Invalid rule specification: %#v\n%v\n", naclRules.Rules[i], err)
	}
	// This is static terraform code which looks up the Network ACL id from a map
	// created in Terraform
	naclRules.Rules[i].NetworkACLID = fmt.Sprintf(`${lookup(local.network_acl_ids_map, "%s")}`, subnetName)
}
generateTfNaclRules(naclRules.Rules)

The generateTfNaclRules function makes use of Golang templates to create the Terraform configuration.

Demo

If we build the code, and run it:

$ ./nacl ./nacl_example.toml

A file SubnetA_nacls.tf will be created as follows:


# This is a generated file, do not hand edit. See README at the
# root of the repository

resource "aws_network_acl_rule" "rule_SubnetA_ingress_101" {

    network_acl_id = "${lookup(local.network_acl_ids_map, "SubnetA")}"
    egress = false
    rule_number = 101
    rule_action = "allow"
    cidr_block = "127.0.0.1/32"
    protocol = "tcp"
    from_port = 22
    to_port = 30


}
resource "aws_network_acl_rule" "rule_SubnetA_egress_102" {

    network_acl_id = "${lookup(local.network_acl_ids_map, "SubnetA")}"
    egress = true
    rule_number = 102
    rule_action = "allow"
    cidr_block = "127.0.0.1/32"
    protocol = "tcp"
    from_port = 22
    to_port = 30


}

Couple of things to note here:

This basically means that if we delete a rule, rule_no from the rules spefication, only a single resource will be deleted.