Invoking the AWS CLI with Terraform

Why?

As awesome and powerful Terraform is, there are times when you find yourself unable to execute certain actions for your automation. This could be due to many reasons including: no Terraform resource for the AWS service, the API action is only available through the CLI/SDK, or you find yourself in a situation where it might be easier to execute an action through the CLI. The situations go on and on however, the point is we all work in varying environments with different resources and constraints.

This article was originally published on Medium. Link to the Medium article can be found here.

How?

At the time of this writing the AWS Route53 resolver endpoint is lacking a Terraform resource(s). However, this does not mean we can’t create the desired resources without Terraform. Let’s take a peek at how we can create a route53 resolver endpoint through Terraform.

Requirements:

  • Ensure the AWS CLI can create the desired resource
  • Have the AWS CLI and required version available in your environment
  • Proper AWS credentials available and configured
  • The AWS CLI is able to create route53 resolver endpoints, both inbound and outbound. Now, let’s get to it!

Example code can be found at https://github.com/karl-cardenas-coding/route53resolver-endpoint

The simplest way to have Terraform execute our CLI command is by leveraging the null_resource and the provisionerlocal-exec. The null_resource won’t create anything, but it allows us to invoke other provisioners. The local exec provisioner allows us to execute a command on the instance where Terraform is currently running.

1
2
3
4
5
resource "null_resource" "create-endpoint" {
  provisioner "local-exec" {
    command = "aws route53resolver create-resolver-endpoint --creator-request-id ${var.creator-request-id} --security-group-ids ${local.security-groups} --direction ${var.direction} --ip-addresses ${local.list-ip-template} --name ${var.endpoint-name} --tags ${var.tags} --profile ${var.aws-profile} > ${data.template_file.log_name.rendered}"
  }
}

So what’s going on above? Well, in simple terms, I am passing in the AWS CLI command for route53resolver create-resolver-endpoint, but rather than hard coding values, I am using interpolation which allows us to turn this into reusable code (terraform module). I am also passing the output of the command into a data template file > ${data.template_file.log_name.rendered} The reason I am passing the output into a data template_file is so that I may later reference the template in order to grab the output and use it as Terraform output variable. The data local_file resource is reading the data template_file rendered output which we then later have the output variable aws-cli-output use as the source for value. The workflow: null_resource... → data.template_file → data.local_file → output

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
data "template_file" "log_name" {
template = "${path.module}/output.log"
}

data "local_file" "create-endpoint" {
filename = "$(data.template_file.log_name.rendered)"
depends_on = ["null_resource.create-endpoint"]
}

# Now we can use the aws cli return text as an output variable
output "aws-cli-output" {
value = "${data.local_file.create-endpoint.content}"
}

Turning console output into a Terraform Output Variable If you take a closer look at the null_resource that invokes the AWS CLI, you’ll see that I am using local variables. I do this, so the module has the flexibility to pass in multiple IP addresses, subnets and security groups. If we look at the AWS CLI documentation, this is what the syntax looks like for multiple IP addresses.

1
--ip-addresses (list) SubnetId=string,Ip=string ...

It would get pretty ugly if we had someone pass a string that looked like something this: SubnetId=subnet-0c198d46,Ip=10.1.1.6 SubnetId=subnet-0c198d58,Ip=10.1.1.7 Not to mention there is a space in the middle of subnets in the second example (ugly indeed…) So how do we overcome this challenge? We can again leverage the data template_file resource to manipulate the string as needed, based on the count of subnets or IP addresses, based on your preference. As you can see below, the locals variables allows us to conduct string manipulation, which is required for the AWS CLI command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
variable "subnet-ids" {
type = "list"
description = "The ID of subnets that contain the IP addresess"
}

variable "ip addresses" {
type = "list"
description = "The IP addresses to apply"
}

data "template_file" "ip-template" {
count = "${length(var.subnet-ids)}"
template = "SubnetId=${var.subnet-ids[count.index]},Ip=${var.ip_addresses[count.index]} "
}
# The locals variable allows us to turn the list into a string
# We need it to be a string for the aws cli command
locals {
list-ip-template = "${join(" "data.template_file.ip-template.*.rendered)}"
}

I do something very similar for the security-group input as the AWS CLI syntax for security groups requires a space between multiple security groups.

1
--security-group-ids (list) "string" "string" ...

Getting Resource Attributes

The downside of creating resources with AWS CLI is Terraform is unable to identify resource ID(s), Arn(s), or other resource attributes that are normally available with native Terraform resources. Even worse, if you needed the IDs or Arn in the future, you’d be stuck with having to hard code the value(s) 💔 Luckily, we have the means and technology to circumvent this limitation! Let’s say we want the resolver-id because we have another module that invokes the create-resolver-rule with the same techniques that we used above. To get the resolver-id, we would query the resolver endpoints by the name that we gave our endpoint. See below.

1
2
3
4
5
6
resource "null_resource" "output-id" {
  provisioner "local-exec" {
    command = "aws route53resolver list-resolver-endpoints --profile ${var.aws-profile} --output text --query 'ResolverEndpoints[?Name==`${var.endpoint-name}`].Id' > ${data.template_file.endpoint-id.rendered}"
  }
  depends_on = ["null_resource.create-endpoint"]
}

Transforming AWS CLI console output into Terraform Output variables So…..a couple of things are happening here, let’s break them down.

  1. We are using the AWS CLI command list-resolver-endpoints, which is also dependent on the resource that creates the resolver endpoints to execute successfully.

  2. We are using the --query parameter with?Name=='${var.endpoint-name}. Essentially, we are only interested in the endpoint and we are using the name we gave it through the Terraform variable to narrow the list down. Finally, we only want the ID as the output.

  3. Again, sending the output to a data template_file and turning it into an output variable.

  4. Bonus: Use the Terraform function trimspace to remove the whitespace otherwise Terraform will gripe when the output variable is used as meta-character such as \n \r are included in the string and we don’t want those to be included.

Deleting the Resource

Same limitation as above, Terraform really has no grasp on the fact that we created a resolver endpoint and as a result terraform destroy would only alter the statefile but not the real infrastructure. All Terraform knows is that it has a null_resource that does something and two output variables that are both of the type string with some values. However, by having the resolver ID we can leverage the AWS CLI to delete the resource for us through the command delete-resolver-endpoint.

1
2
3
4
5
6
7
resource "null_resource" "deleteEndpoint" {
  count = "${var.delete != "false" ? 1 :0}"
  provisioner "local-exec" {
    command = "aws route53resolver delete-resolver-endpoint resolver-endpoint-id ${trimspace(data.local_file.readId.content)} --profile ${var.aws-profile} > ${data.template_file.log_name.rendered}"
  }
  depends_on = ["null_resource.create-endpoint"]
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
resource "null_resource" "output-id" {
  provisioner "local-exec" {
    command = "aws routes3resolver list-resolver-endpoints --profile ${var.aws-profile} --output text --query 'ResolverEndpoints[?Name==`${var.endpoint-name}`].Id' > ...
  }
  depends_on = ["null_resource.create-endpoint]
}

data "template file" "endpoint-id" {
template = "${path.module}/id.log"
}

data "local_file" "readId" {
filename = "${data.template_file.endpoint-id.rendered}"
depends_on = ["null_resource.output-id]
}

output "endpoint-id" {
value = "${trimspace(data.local_file.readId.content)}"
}

Now we are coming full circle: we are executing another AWS CLI command.

The resolver id is referenced in the command through ${trimspace(data.local_file.readId.content)} Send output to a data template_file and turn it into an output variable, however, this time we are re-using the original template file from the resource creation command. This will allow us to override the output in the statefile and gives us details about the delete process. We need to leverage the same output file since we are unable to apply logic on output variables.

The ${var.delete} is what enables us to delete the resolver-endpoint. It’s default value is false and when the value is false, the count will = 0 thus no delete action occurring. But should delete = "true" then Terraform will create the null_resource.deleteEndpoint and our AWS CLI command will execute.

Conclusion

I will be the first one to admit that this is not my favorite solution, but I wanted to share this with everyone because it’s better than having manual actions occur outside of our automation orchestration and prevent state drift. This solution allows you to still create a non-Terraform available resource and still have a way of maintaining the resources through Terraform.

These techniques can be extended to other AWS CLI calls or functions not related to the AWS CLI:

  • Invoking a Python script that deletes the default VPC upon AWS account creation
  • Move an account into its respective OU
  • Remove the default FullAWSAccess Service Control Policy after attaching an SCP

These are just some ideas where invoking the CLI can help you and your team move closer towards the automation nirvana.

0%