Don't let your Terraform go rogue with Conftest and the Open Policy Agent
Tags: terraform, devops
Published: 2021-06-25 20:36:24

## Overview

[Terraform](https://www.terraform.io/) 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](https://github.com/open-policy-agent/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](https://www.terraform.io/docs/internals/json-format.html) 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
}
```

This code creates a [null_resource](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource#schema), 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"
}
```

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](https://www.openpolicyagent.org/docs/latest/policy-language/#built-in-functions).
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](https://www.conftest.dev/install/).

`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",
                    }
                ]
            }
        },
  }
}
```

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](https://www.openpolicyagent.org/docs/v0.11.0/get-started/#prerequisites).

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!