DEV Community

Shannon
Shannon

Posted on • Edited on

Don't let your Terraform go rogue with Conftest and the Open Policy Agent

Overview

Terraform is an incredible resource to turn any API into declarative, configurable code with Hashicorp's HCL language. However, the world is often at your fingertips when creating cloud resources with Terraform. Frequently, an HCL codebase is run through a CI/CD pipeline, and without proper checks or human involvement, a developer can destroy or change critical resources in production environments.

Insert Conftest! As they state in their GitHub description, Conftest tests against structured configuration data using the Open Policy Agent Rego query language. In the case of Terraform, this means we're actually running unit tests against sample JSON and actual tests against the Terraform plan JSON.

Writing some basic Terraform to test

Every time you plan or apply Terraform, the output of this plan, apply, or destroy can be viewed locally in order to see the potential changes. Note: this is only in Terraform >= 0.12. In order to test our Terraform, we will be generating the output of a plan in our current working directory.

Every Terraform plan output follows a similar pattern, and we can parse this in order to test whether specific resources are being modified, created, or deleted. First up, we're going to make some really simple Terraform resources. Create a new directory and add the following code to a main.tf file:

terraform {
    required_providers {}
}

resource "null_resource" "fake_instance" {
    count = 2
}
Enter fullscreen mode Exit fullscreen mode

This code creates a null_resource, which implements the standard resource lifecycle but takes no further action. Meaning, we're not really doing anything with this Terraform other than generating some resources for our Terraform file. Specifically, we're creating 2 resources because count = 2.

Creating the Terraform file

Once you've created this file, it just takes a few CLI commands with the Terraform binary in order to generate a file:

  • Initialize the Terraform: terraform init
  • Create a plan: terraform plan -out=tfplan_2_resources_planned
  • Generate a JSON file of the Terraform plan: terraform show -json ./tfplan_2_resources_planned | jq > tfplan_2_resources_planned.json

Analyzing the Terraform plan JSON file with JQ

Now, you should have a JSON file in your local working directory that is parseable to see the patterns Terraform establishes to show info about any Terraform resource being created, changed, or destroyed. We can utilize jq to view a few of these, but I'd definitely suggest just scrolling through the file yourself!

  • Resource changes: cat tfplan_2_resources_planned.json | jq .resource_changes
  • Type of resources being changed: cat tfplan_2_resources_planned.json | jq '.resource_changes[].type'
  • Resource action (change, create, destroy): cat tfplan_2_resources_planned.json | jq '.resource_changes[].change.actions[]'

There are many, many possibilities for what can be tested in this file. For instance, I could confirm that the Terraform version is not changed by looking at the terraform_version key in the state file or whether the AWS provider is using the official release. It's really worth digging into this file's structure.

Writing Rego to parse the JSON file

First up, we'll want to make a directory: mkdir policy. It's idiomatic to have the directory called policy for Conftest. We'll write some simple Rego that checks how many null_resource objects we are creating. Name this file main.rego.

package main

planned_resources = [res | 
  res := input.planned_values.root_module.resources[_]
  res.type == "null_resource"
]

num_planned_resources := count(planned_resources)

deny[msg] {
  not num_planned_resources == 2
  msg := "there should be 2 total null_resources"
}
Enter fullscreen mode Exit fullscreen mode

Some things to note about the code above:

  1. You must have a package declared. This structure is similar to Go and you may import other packages.
  2. planned_resources is using a list comprehension, which is common in Python. In this case, it is parsing the input, which in our case is the Terraform plan file. Then, it narrows down the JSON to planned_values.root_module.resources[_]. The [_] indicates that you are searching all objects. Finally, it narrows down the objects to only have a type of null_resource. In our case, that's all we're creating, but typically your Terraform will have many, many more resources.
  3. count() is a built-in function. There are many such functions, which can be seen here.
  4. deny[msg] is the common block that Conftest is checking and will provide a status on whether the test fails. Inside the block, if anything evaluates to true, then the deny block will fail and return the msg variable. Thus, that is why we declare the the test as not == 2.

Rego is particularly confusing to me as it gets more complicated, so if it doesn't make total sense at first, don't be discouraged! It took quite a bit of time for me to parse what's really going on with OPA and Rego (and I'm often still confused).

Now, we can test the code with the Conftest CLI. If you don't have that installed, here's the installation guide.

conftest test tfplan_2_resources_planned.json should generate something like: 1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions. As mentioned above, it's idiomatic that Conftest will look in the directory ./policy for Rego policies. If you do not have it in that directory, this also works: conftest test --policy [location_here] tfplan_2_resources_planned.json

Unit testing your Rego

It's relatively simple to create mock input for unit tests, so here is an example of testing how many null_resources are being created. Make a file called main_test.rego with the following content:

test_num_planned_resources {
    num_planned_resources == 1 with input as {
        "planned_values": {
            "root_module": {
                "resources": [
                    {
                        "address": "null_resource.fake_instance[0]",
                        "type": "null_resource",
                    }
                ]
            }
        },
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, I'm confirming that 1 null_resource object is being created with the mocked out JSON.

To run this, you'll also need the OPA binary installed. Here is an installation guide from the docs.

And finally, run this command: opa test -v policy/*.rego

And that's it! You now have code that tests you're creating exactly 2 null resources. Run through the same process above with a different number of resources, and you will see the Conftest test fails. Similarly, you can apply these resources and then try deleting them to see what other areas of your Terraform code can be tested.

Real world example

Now that we have a general understanding of using Rego to test our Terraform, here's a pretty common workflow that you could expect to see with Conftest:

  1. Introduce new Terraform code on a new Git branch
  2. CI kicks off a new build based off the Git commit that outputs a Terraform plan binary
  3. Conftest binary runs against this Terraform plan. If it fails, the entire build fails and developer is notified. Build ends.
  4. If Conftest passes successfully, code is merged into main/master branch
  5. Terraform is applied and new infrastructure deployed

Au revoir

I hope this was helpful and don't hesitate to ask questions!

Top comments (3)

Collapse
 
anderseknert profile image
Anders Eknert

Great blog! Thanks for sharing :)

Collapse
 
lucassha profile image
Shannon

Thanks, Anders! Did not expect to see an OPA dev advocate comment on this! Thanks for the great work y'all do

Collapse
 
anderseknert profile image
Anders Eknert

Thanks Shannon! If you haven't already, feel free to join the OPA Slack ( slack.openpolicyagent.org/ ). Great way to share content like this with the OPA community, ask Rego related questions, etc. Either way, I'll be following your blogging!