1# Terraform Testing 2 3Terraform lets you describe the infrastructure you want and automatically creates, deletes, and modifies 4your existing infrastructure to match. OPA makes it possible to write policies that test the changes 5Terraform is about to make before it makes them. Such tests help in different ways: 6* tests help individual developers sanity check their Terraform changes 7* tests can auto-approve run-of-the-mill infrastructure changes and reduce 8the burden of peer-review 9* tests can help catch problems that arise when applying Terraform to production after applying it to staging 10 11 12## Goals 13 14In this tutorial, you'll learn how to use OPA to implement unit tests for Terraform plans that create 15and delete auto-scaling groups and servers. 16 17## Prerequisites 18 19This tutorial requires 20 21* [Terraform 0.8](https://releases.hashicorp.com/terraform/0.8.8/) 22* [OPA](https://github.com/open-policy-agent/opa/releases) 23* [tfjson](https://github.com/palantir/tfjson) (`go get github.com/palantir/tfjson`): a Go utility that converts Terraform plans into JSON 24 25(This tutorial *should* also work with the [latest version of Terraform](https://www.terraform.io/downloads.html) 26and the [latest version of tfjson](https://github.com/philips/tfjson), but it is untested. Contributions welcome!) 27 28## Steps 29 30### 1. Create and save a Terraform plan 31 32Create a [Terraform](https://www.terraform.io/docs/index.html) file that includes an 33auto-scaling group and a server on AWS. (You will need to modify the `shared_credentials_file` 34to point to your AWS credentials.) 35 36```shell 37cat >main.tf <<EOF 38provider "aws" { 39 region = "us-west-1" 40} 41resource "aws_instance" "web" { 42 instance_type = "t2.micro" 43 ami = "ami-09b4b74c" 44} 45resource "aws_autoscaling_group" "my_asg" { 46 availability_zones = ["us-west-1a"] 47 name = "my_asg" 48 max_size = 5 49 min_size = 1 50 health_check_grace_period = 300 51 health_check_type = "ELB" 52 desired_capacity = 4 53 force_delete = true 54 launch_configuration = "my_web_config" 55} 56resource "aws_launch_configuration" "my_web_config" { 57 name = "my_web_config" 58 image_id = "ami-09b4b74c" 59 instance_type = "t2.micro" 60} 61EOF 62``` 63 64Then ask Terraform to calculate what changes it will make and store the output in `plan.binary`. 65 66```shell 67terraform plan --out tfplan.binary 68``` 69 70### 2. Convert the Terraform plan into JSON 71 72Use the `tfjson` tool to convert the Terraform plan into JSON so that OPA can read the plan. 73 74```shell 75tfjson tfplan.binary > tfplan.json 76``` 77 78Here is the expected contents of `tfplan.json`. 79``` 80{ 81 "aws_autoscaling_group.my_asg": { 82 "arn": "", 83 "availability_zones.#": "1", 84 "availability_zones.3205754986": "us-west-1a", 85 "default_cooldown": "", 86 "desired_capacity": "4", 87 "destroy": false, 88 "destroy_tainted": false, 89 "force_delete": "true", 90 "health_check_grace_period": "300", 91 "health_check_type": "ELB", 92 "id": "", 93 "launch_configuration": "my_web_config", 94 "load_balancers.#": "", 95 "max_size": "5", 96 "metrics_granularity": "1Minute", 97 "min_size": "1", 98 "name": "my_asg", 99 "protect_from_scale_in": "false", 100 "vpc_zone_identifier.#": "", 101 "wait_for_capacity_timeout": "10m" 102 }, 103 "aws_instance.web": { 104 "ami": "ami-09b4b74c", 105 "associate_public_ip_address": "", 106 "availability_zone": "", 107 "destroy": false, 108 "destroy_tainted": false, 109 "ebs_block_device.#": "", 110 "ephemeral_block_device.#": "", 111 "id": "", 112 "instance_state": "", 113 "instance_type": "t2.micro", 114 "ipv6_addresses.#": "", 115 "key_name": "", 116 "network_interface_id": "", 117 "placement_group": "", 118 "private_dns": "", 119 "private_ip": "", 120 "public_dns": "", 121 "public_ip": "", 122 "root_block_device.#": "", 123 "security_groups.#": "", 124 "source_dest_check": "true", 125 "subnet_id": "", 126 "tenancy": "", 127 "vpc_security_group_ids.#": "" 128 }, 129 "aws_launch_configuration.my_web_config": { 130 "associate_public_ip_address": "false", 131 "destroy": false, 132 "destroy_tainted": false, 133 "ebs_block_device.#": "", 134 "ebs_optimized": "", 135 "enable_monitoring": "true", 136 "id": "", 137 "image_id": "ami-09b4b74c", 138 "instance_type": "t2.micro", 139 "key_name": "", 140 "name": "my_web_config", 141 "root_block_device.#": "" 142 }, 143 "destroy": false 144} 145``` 146 147### 3. Write the OPA policy to check the plan 148 149The policy computes a score for a Terraform that combines 150* The number of deletions of each resource type 151* The number of creations of each resource type 152* The number of modifications of each resource type 153 154The policy authorizes the plan when the score for the plan is below a threshold 155and there are no changes made to any IAM resources. 156(For simplicity, the threshold in this tutorial is the same for everyone, but in 157practice you would vary the threshold depending on the user.) 158 159```shell 160cat >terraform.rego <<EOF 161package terraform.analysis 162 163import input as tfplan 164 165######################## 166# Parameters for Policy 167######################## 168 169# acceptable score for automated authorization 170blast_radius = 30 171 172# weights assigned for each operation on each resource-type 173weights = { 174 "aws_autoscaling_group": {"delete": 100, "create": 10, "modify": 1}, 175 "aws_instance": {"delete": 10, "create": 1, "modify": 1} 176} 177 178# Consider exactly these resource types in calculations 179resource_types = {"aws_autoscaling_group", "aws_instance", "aws_iam", "aws_launch_configuration"} 180 181######### 182# Policy 183######### 184 185# Authorization holds if score for the plan is acceptable and no changes are made to IAM 186default authz = false 187authz { 188 score < blast_radius 189 not touches_iam 190} 191 192# Compute the score for a Terraform plan as the weighted sum of deletions, creations, modifications 193score = s { 194 all = [ x | 195 weights[resource_type] = crud; 196 del = crud["delete"] * num_deletes[resource_type]; 197 new = crud["create"] * num_creates[resource_type]; 198 mod = crud["modify"] * num_modifies[resource_type]; 199 x1 = del + new 200 x = x1 + mod 201 ] 202 sum(all, s) 203} 204 205# Whether there is any change to IAM 206touches_iam { 207 all = instance_names["aws_iam"] 208 count(all, c) 209 c > 0 210} 211 212#################### 213# Terraform Library 214#################### 215 216# list of all resources of a given type 217instance_names[resource_type] = all { 218 resource_types[resource_type] 219 all = [name | 220 tfplan[name] = _ 221 startswith(name, resource_type) 222 ] 223} 224 225# number of deletions of resources of a given type 226num_deletes[resource_type] = num { 227 resource_types[resource_type] 228 all = instance_names[resource_type] 229 deletions = [name | all[_] = name; tfplan[name]["destroy"] = true] 230 count(deletions, num) 231} 232 233# number of creations of resources of a given type 234num_creates[resource_type] = num { 235 resource_types[resource_type] 236 all = instance_names[resource_type] 237 creates = [name | all[_] = name; tfplan[name]["id"] = ""] 238 count(creates, num) 239} 240 241# number of modifications to resources of a given type 242num_modifies[resource_type] = num { 243 resource_types[resource_type] 244 all = instance_names[resource_type] 245 modifies = [name | all[_] = name; obj = tfplan[name]; obj["destroy"] = false; not obj["id"]] 246 count(modifies, num) 247} 248EOF 249``` 250 251### 4. Evaluate the OPA policy on the Terraform plan 252 253To evaluate the policy against that plan, you hand OPA the policy, the Terraform plan as input, and 254ask it to evaluate `data.terraform.analysis.authz`. 255 256```shell 257opa eval --data terraform.rego --input tfplan.json "data.terraform.analysis.authz" 258``` 259 260If you're curious, you can ask for the score that the policy used to make the authorization decision. 261In our example, it is 11 (10 for the creation of the auto-scaling group and 1 for the creation of the server). 262 263```shell 264opa eval --data terraform.rego --input tfplan.json "data.terraform.analysis.score" 265``` 266 267If as suggested in the previous step, you want to modify your policy to make an authorization decision 268based on both the user and the Terraform plan, the input you would give to OPA would take the form 269`{"user": <user>, "plan": <plan>}`, and your policy would reference the user with `input.user` and 270the plan with `input.plan`. You could even go so far as to provide the Terraform state file and the AWS 271EC2 data to OPA and write policy using all of that context. 272 273### 5. Create a Large Terraform plan and Evaluate it 274 275Create a Terraform plan that creates enough resources to exceed the blast-radius permitted 276by policy. 277 278```shell 279cat >main.tf <<EOF 280provider "aws" { 281 region = "us-west-1" 282} 283resource "aws_instance" "web" { 284 instance_type = "t2.micro" 285 ami = "ami-09b4b74c" 286} 287resource "aws_autoscaling_group" "my_asg" { 288 availability_zones = ["us-west-1a"] 289 name = "my_asg" 290 max_size = 5 291 min_size = 1 292 health_check_grace_period = 300 293 health_check_type = "ELB" 294 desired_capacity = 4 295 force_delete = true 296 launch_configuration = "my_web_config" 297} 298resource "aws_launch_configuration" "my_web_config" { 299 name = "my_web_config" 300 image_id = "ami-09b4b74c" 301 instance_type = "t2.micro" 302} 303resource "aws_autoscaling_group" "my_asg2" { 304 availability_zones = ["us-west-2a"] 305 name = "my_asg2" 306 max_size = 6 307 min_size = 1 308 health_check_grace_period = 300 309 health_check_type = "ELB" 310 desired_capacity = 4 311 force_delete = true 312 launch_configuration = "my_web_config" 313} 314resource "aws_autoscaling_group" "my_asg3" { 315 availability_zones = ["us-west-2b"] 316 name = "my_asg3" 317 max_size = 7 318 min_size = 1 319 health_check_grace_period = 300 320 health_check_type = "ELB" 321 desired_capacity = 4 322 force_delete = true 323 launch_configuration = "my_web_config" 324} 325EOF 326``` 327 328Generate the Terraform plan and convert it to JSON. 329 330```shell 331terraform plan --out tfplan_large.binary 332tfjson tfplan_large.binary > tfplan_large.json 333``` 334 335Evaluate the policy to see that it fails the policy tests and check the score. 336 337```shell 338opa eval --data terraform.rego --input tfplan_large.json "data.terraform.analysis.authz" 339opa eval --data terraform.rego --input tfplan_large.json "data.terraform.analysis.score" 340``` 341 342 343### 6. (Optional) Run OPA as a daemon and evaluate policy 344 345In addition to running OPA from the command-line, you can run it as a daemon loaded with the Terraform policy and 346then interact with it using its HTTP API. First, start the daemon: 347 348```shell 349opa run -s terraform.rego 350``` 351 352Then in a separate terminal, use OPA's HTTP API to evaluate the policy against the two Terraform plans. 353 354```shell 355curl localhost:8181/v0/data/terraform/analysis/authz -d @tfplan.json 356curl localhost:8181/v0/data/terraform/analysis/authz -d @tfplan_large.json 357``` 358 359 360## Wrap Up 361 362Congratulations for finishing the tutorial! 363 364You learned a number of things about Terraform Testing with OPA: 365 366* OPA gives you fine-grained policy control over Terraform plans. 367* You can use data other than the plan itself (e.g. the user) when writing authorization policies. 368 369Keep in mind that it's up to you to decide how to use OPA's Terraform tests and authorization decision. Here are some ideas. 370* Add it as part of your Terraform wrapper to implement unit tests on Terraform plans 371* Use it to automatically approve run-of-the-mill Terraform changes to reduce the burden of peer-review 372* Embed it into your deployment system to catch problems that arise when applying Terraform to production after applying it to staging 373