In-line For loop over a data-map object into a jsonencode structure

I feel like I’m so close but can’t figure out the last bit of formatting when I loop through my data map to create a CloudWatch Dashboard.

Terraform Version

Terraform v1.5.6

Here’s what my code produces:

Terraform will perform the following actions:

  # module.test_app_servers_cloudwatch.aws_cloudwatch_dashboard.dashboard will be updated in-place
  ~ resource "aws_cloudwatch_dashboard" "dashboard" {
      ~ dashboard_body = jsonencode(
          ~ {
              ~ widgets = [
                  + {
                      + "0" = {
                          + properties = {
                              + metrics = [
                                  + "CWAgent",
                                  + "LogicalDisk",
                                  + "% Free Space",
                                  + "Instance",
                                  + "C:",
                                  + "InstanceId",
                                  + "i-01234567890",
                                  + "objectname",
                                  + "LogicalDisk",
                                  + "ImageId",
                                  + "ami-01234567890",
                                  + "InstanceType",
                                  + "t3a.small",
                                ]
                              + period  = 300
                              + region  = "us-west-1"
                              + stat    = "Average"
                              + title   = "Disk Usage : TESTAPP01"
                            }
                          + type       = "metric"
                          + width      = 12
                        }
                      + "1" = {
                          + properties = {
                              + metrics = [
                                  + "CWAgent",
                                  + "LogicalDisk",
                                  + "% Free Space",
                                  + "Instance",
                                  + "C:",
                                  + "InstanceId",
                                  + "i-09876543210",
                                  + "objectname",
                                  + "LogicalDisk",
                                  + "ImageId",
                                  + "ami-01234567890",
                                  + "InstanceType",
                                  + "t3a.small",
                                ]
                              + period  = 300
                              + region  = "us-west-1"
                              + stat    = "Average"
                              + title   = "Disk Usage : TESTAPP02"
                            }
                          + type       = "metric"
                          + width      = 12
                        }
                    },
                ]
            }
        )
        id             = "TESTAPP-test"
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

…as you can see, I’m getting unusable JSON back where my loop structure is producing the keys [ {“0”} and {“1”} ] within the dashboard_body and is producing the following error:

│ Error: Putting dashboard failed: InvalidParameterInput: The dashboard body is invalid, there are 2 validation errors:
│ [
│   {
│     "dataPath": "/widgets/0",
│     "message": "Should have required property 'type'"
│   },
│   {
│     "dataPath": "/widgets/0",
│     "message": "Should have required property 'properties'"
│   }
│ ]

The Code:

resource "aws_cloudwatch_dashboard" "dashboard" {
    dashboard_name = "${var.customer_name}-${var.environment}"
    dashboard_body = jsonencode({
        widgets = [
            {
                for instance, data in local.instance_data_map[*] : instance => {
                    type = "metric"
                    width = 12
                    properties = {
                        metrics = [
                            "CWAgent",
                            "LogicalDisk",
                            "% Free Space",
                            "Instance",
                            "C:",
                            "InstanceId",
                            data.id,
                            "objectname",
                            "LogicalDisk",
                            "ImageId",
                            data.ami,
                            "InstanceType",
                            data.type
                        ],
                        period = 300
                        stat = "Average"
                        region = "${var.region}"
                        title = "Disk Usage : ${data.name}"
                    }
                }
            }
        ]
    })
}

The idea is to create multiple widgets where I plan to display all of the instance’s disks in the widget, 1 widget per instance, if that makes sense.

Alternatively, I’ve also tried the following code:

resource "aws_cloudwatch_dashboard" "dashboard" {
    dashboard_name = "${var.customer_name}-${var.environment}"
    dashboard_body = jsonencode({
        widgets = [
            flatten([for instance in local.instance_data_map[*] : [
                {
                    type = "metric"
                    width = 12
                    properties = {
                        metrics = [
                            "CWAgent",
                            "LogicalDisk",
                            "% Free Space",
                            "Instance",
                            "C:",
                            "InstanceId",
                            "${instance.id}",
                            "objectname",
                            "LogicalDisk",
                            "ImageId",
                            "${instance.ami}",
                            "InstanceType",
                            "${instance.type}"
                        ],
                        period = 300
                        stat = "Average"
                        region = "${var.region}"
                        title = "Disk Usage : ${instance.name}"
                    }
                }
            ]])
        ]
    })
}

…which produces the following unusable JSON structure due to the tuple’s brackets:

Terraform will perform the following actions:

  # module.test_app_servers_cloudwatch.aws_cloudwatch_dashboard.dashboard will be updated in-place
  ~ resource "aws_cloudwatch_dashboard" "dashboard" {
      ~ dashboard_body = jsonencode(
          ~ {
              ~ widgets = [
                  + [
                      + {
                          + properties = {
                              + metrics = [
                                  + "CWAgent",
                                  + "LogicalDisk",
                                  + "% Free Space",
                                  + "Instance",
                                  + "C:",
                                  + "InstanceId",
                                  + "i-01234567890",
                                  + "objectname",
                                  + "LogicalDisk",
                                  + "ImageId",
                                  + "ami-01234567890",
                                  + "InstanceType",
                                  + "t3a.small",
                                ]
                              + period  = 300
                              + region  = "us-west-1"
                              + stat    = "Average"
                              + title   = "Disk Usage : TESTAPP01"
                            }
                          + type       = "metric"
                          + width      = 12
                        },
                      + {
                          + properties = {
                              + metrics = [
                                  + "CWAgent",
                                  + "LogicalDisk",
                                  + "% Free Space",
                                  + "Instance",
                                  + "C:",
                                  + "InstanceId",
                                  + "i-09876543210",
                                  + "objectname",
                                  + "LogicalDisk",
                                  + "ImageId",
                                  + "ami-01234567890",
                                  + "InstanceType",
                                  + "t3a.small",
                                ]
                              + period  = 300
                              + region  = "us-west-1"
                              + stat    = "Average"
                              + title   = "Disk Usage : TESTAPP02"
                            }
                          + type       = "metric"
                          + width      = 12
                        },
                    ],
                ]
            }
        )
        id             = "TESTAPP-test"
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

…which produces the error:

Error: Putting dashboard failed: InvalidParameterInput: The dashboard body is invalid, there are 1 validation errors:
│ [
│   {
│     "dataPath": "/widgets/0",
│     "message": "Should be object"
│   }
│ ]

But as you saw earlier, when I send an object, I’m stuck with the keys that further invalidates the JSON code.

And here is the way I’m building my “data-map” object:

locals {
    instance_data_map = flatten([
        for instance, data in data.aws_instance.instance[*] : [
            for key, value in data : {
                name = key
                id = value.instance_id
                type = value.instance_type
                ami = value.ami
            }
        ]
    ])
}

The Data Source is the return objects when I pass in the instance-id and am building the “Data-Map” object with only the information I need for the dashboard.

I would certainly appreciate any input here. so far I feel like I’ve tried everything I could find online but the two above are the only ways I can even generate a TF PLan w/o errors, let alone even to try to apply it.

Thanks in advance!

Solved this myself and wanted to leave behind what I had to do for other fairly intermediate Terraform folks out there dealing with similar issues…

So as the more seasoned Terraform folks out there would immediately notice is that not only was my mapping incomplete but also my approach needed fixing as my initial run at this was terribly inefficient and was why I kept getting bad JSON when my loops executed in the resource block. I fixed this by firstly, folding all CloudWatch resources into my EC2 Module. This way TF can more efficiently provision resources w/o needing error-prone and sometimes unreliable cross-module “depends_on” statements.

Secondly, I needed to re-create my maps:
server_map:

server_map = flatten([for instance, instance_config in var.server_map : {
    identifier = instance
    ami = instance_config["ami"]
    instance_type = instance_config["instance_type"]
    availability_zone = instance_config["availability_zone"]
    iam_instance_profile = instance_config["iam_instance_profile"]
    key_name = instance_config["key_name"]
    root_volume_type = instance_config["root_volume_type"]
    root_volume_throughput = instance_config["root_volume_throughput"]
    root_volume_iops = instance_config["root_volume_iops"]
    root_volume_size = instance_config["root_volume_size"]
    network_interface = {for iface, iface_config in instance_config["network_interface"] : iface => {
        device_index = iface
        iface_identifier = "${instance}-${iface_config["network_interface_description"]}"
    }}
    ebs_block_device = {for disk, disk_config in instance_config["ebs_block_device"] : disk => {
        disk_map = trimprefix(disk, "xvd")
    }}
    tags = {for tag, tag_value in instance_config["tags"] : tag => tag_value}
}])

…this is derived from a local.tf file that I send to the Module:

locals {
    test_servers = {
        FAKEAPPSERVER01 : {
            ami : data.aws_ami.some_ami_datasource.id
            instance_type : "t3a.small"
            availability_zone : "us-west-1a"
            iam_instance_profile : "FakeIAMProfile"
            key_name : "fakekey"
            root_volume_type : "standard"
            root_volume_size : 40
            root_volume_throughput : null
            root_volume_iops : null
            ebs_block_device : {
                xvdd : {
                    availability_zone : "us-west-1a"
                    delete_on_termination : true
                    encrypted : true
                    volume_type : "gp3"
                    volume_size : 25
                    volume_throughput : null
                    volume_iops : null
                    tags : {}
                },
            }
            network_interface : {
                0 : {
                    network_interface_description : "eth0"
                    subnet_id : "subnet-fakesubnet01"
                    security_groups : [
                        data.aws_security_group.default.id,
                        element(module.security_groups.security_group_ids[*]["FakeSecurityGroup"], 0)
                    ]
                    private_ips_count : 1
                }
            }
        }
    }
}

cloudwatch_instance_map:

cloudwatch_instance_map = flatten([for instance, instance_config in local.server_map : {
    identifier = instance_config["identifier"]
    instance_type = instance_config["instance_type"]
    ami = instance_config["ami"]
    disk_map = concat(["c"], [for disk in instance_config["ebs_block_device"] : disk.disk_map])
}])

cloudwatch_disk_map:

cloudwatch_disks = flatten([for instance, instance_config in local.cloudwatch_instance_map : {
    for disk in instance_config.disk_map : "${instance_config["identifier"]}-${disk}" => {
        disk_identifier = "${instance_config["identifier"]}-${upper(disk)}:"
        disk_name = "${upper(disk)}:"
        instance_identifier = instance_config["identifier"]
        instance_type = instance_config["instance_type"]
        instance_ami = instance_config["ami"]
    }
}])

cloudwatch_disk_map_merged:

cloudwatch_disk_map = merge(local.cloudwatch_disks...)

…and finally my module needed changing:

resource "aws_cloudwatch_dashboard" "dashboard" {
    dashboard_name = "${var.customer_name}-${var.environment}"
    dashboard_body = jsonencode({
        widgets = flatten([
            {
                type = "metric"
                width = 12
                properties = {
                    region = var.region
                    period = 300
                    stat = "Average"
                    title = "Disk Usage"
                    metrics = [for disk, disk_config in local.cloudwatch_disk_map : 
                        [
                            "CWAgent",
                            "LogicalDisk % Free Space",
                            "instance",
                            "${disk_config.disk_name}",
                            "InstanceId",
                            "${aws_instance.instance[disk_config.instance_identifier].id}",
                            "objectname",
                            "LogicalDisk",
                            "ImageId",
                            "${disk_config.instance_ami}",
                            "InstanceType",
                            "${disk_config.instance_type}",
                            {
                                "label" : "${disk_config.disk_identifier}"
                            }
                        ]
                    ]
                }
            },
        ])
    })
}

I have to admit, this took me an embarrassingly long time to figure out but I’m glad I kept at it because I found out more than I care to know about Terraform loops and data structures, and that if the data doesn’t fit, restructure it into a map that does (probably old hat for more seasoned Terraform vets).

I’m sure my solution here is clunky and inefficient so please let me know if anyone has any advice.