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