├── .github └── workflows │ └── go.yml ├── .gitignore ├── CONTRIBUTING.md ├── GNUmakefile ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── terraformtest.go ├── terraformtest_test.go └── testdata ├── ekspoc.plan.json ├── terraform-aws-101.plan.json └── terraform.plan.json /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Set up Go 1.x 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: "^1.16" 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v2 21 | 22 | - name: Get dependencies 23 | run: | 24 | go get -v -d ./... 25 | 26 | - name: Build 27 | run: go build -v ./... 28 | 29 | - name: Test 30 | run: go test -v ./... 31 | 32 | lint: 33 | name: Lint 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Set up Go 1.x 37 | uses: actions/setup-go@v2 38 | with: 39 | go-version: "^1.16" 40 | 41 | - name: Get golint 42 | run: go get -u golang.org/x/lint/golint 43 | 44 | - name: Check out code into the Go module directory 45 | uses: actions/checkout@v2 46 | 47 | - name: Get dependencies 48 | run: | 49 | go get -v -d ./... 50 | 51 | - name: Golint 52 | run: $(go env GOPATH)/bin/golint -set_exit_status ./... 53 | 54 | - name: Format 55 | run: | 56 | if [[ $(gofmt -d . | wc -c) -ne 0 ]]; then 57 | gofmt -d . 58 | exit 1 59 | fi 60 | 61 | - name: Vet 62 | run: go vet ./... 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | .terraform 18 | terraform.tfstate 19 | terraform.tfstate.* 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # WIP 2 | -------------------------------------------------------------------------------- /GNUmakefile: -------------------------------------------------------------------------------- 1 | TEST?=$$(go list ./...) 2 | 3 | default: test 4 | 5 | test: lint fmt 6 | go test -v -timeout=30s $(TEST) 7 | 8 | actions: 9 | @echo "==> Running act with big image of ubuntu 18.04" 10 | @echo "==> Warning - this image is >18GB" 11 | act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04 12 | 13 | fmt: 14 | @echo "==> Fixing source code with gofmt..." 15 | gofmt -w -s . 16 | 17 | lint: 18 | @echo "==> Checking source code against linters..." 19 | golint -set_exit_status ./... 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thiago Nache Carvalho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unit testing terraform 2 | 3 | ![Go](https://github.com/thiagonache/terraformtest/workflows/Go/badge.svg?branch=master) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/thiagonache/terraformtest)](https://goreportcard.com/report/github.com/thiagonache/terraformtest) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/thiagonache/terraformtest.svg)](https://pkg.go.dev/github.com/thiagonache/terraformtest) 6 | 7 | ## Import 8 | 9 | ```go 10 | import "github.com/thiagonache/terraformtest" 11 | ``` 12 | 13 | ## Summary 14 | 15 | Terraformtest is a lightweight terraform tester written in Go for unit and integration tests. 16 | 17 | ### Features 18 | 19 | - Remove code paperwork to write tests. 20 | - No JSON query required for testing resources. 21 | - Terraform resources are abstract in a Go generic struct. 22 | - Test number of resources wanted in a plan. 23 | - Test if a wanted resource exist in a plan. 24 | - Test if all wanted resources exist in a plan and vice-versa. 25 | 26 | ### Motivation 27 | 28 | ![Martin's Folwer Pyramid 29 | Tests](https://3fxtqy18kygf3on3bu39kh93-wpengine.netdna-ssl.com/wp-content/uploads/2020/01/test-automation-pyramid.jpg) 30 | 31 | - We want to test the logic of our code without having to deploy resources. 32 | 33 | - We want to ensure behavior by testing resource values. 34 | 35 | - The most unit tests the better so they must be fast. 36 | 37 | - Should be simple to write tests so the writer doesn't need to be an expert on Go 38 | or Terraform. 39 | 40 | ## Testing your plan 41 | 42 | ### Generate Terraform plan in JSON format 43 | 44 | 1. Run plan with plan in binary 45 | 46 | ```shell 47 | terraform plan -out /tmp/mymodule.plan 48 | ``` 49 | 50 | 1. Convert binary plan into JSON file. 51 | 52 | ```shell 53 | terraform show -json /tmp/mymodule.plan > /any/path/i/want/mymodule.plan.json 54 | ``` 55 | 56 | ### Test if plan contains one or more resources 57 | 58 | ```go 59 | func TestContainsResource(t *testing.T) { 60 | testCases := []struct { 61 | desc, planJSONPath string 62 | wantResource terraformtest.Resource 63 | }{ 64 | { 65 | // Test description 66 | desc: "Test EIP", 67 | // Path for Terraform plan with resources to be tested in JSON format. 68 | planJSONPath: "testdata/terraform-aws-101.plan.json", 69 | wantResource: terraformtest.Resource{ 70 | // Resource address as show in the output of terraform plan command. 71 | Address: "module.vpc.aws_eip.nat[0]", 72 | // Metadata represents the resource type and the resource name in the resource declaration. 73 | // Eg.: resource "aws_eip" "nat" { 74 | Metadata: map[string]string{ 75 | "type": "aws_eip", 76 | "name": "nat", 77 | }, 78 | // Values are the resources key => value. Anything inside of the planned_values in the JSON file. 79 | Values: map[string]string{ 80 | "vpc": "true", 81 | "timeouts": "", 82 | }, 83 | }, 84 | }, 85 | { 86 | desc: "Test DB Subnet Group", 87 | planJSONPath: "testdata/terraform-aws-101.plan.json", 88 | wantResource: terraformtest.Resource{ 89 | Address: "module.db.module.db_subnet_group.aws_db_subnet_group.this[0]", 90 | Metadata: map[string]string{ 91 | "type": "aws_db_subnet_group", 92 | "name": "this", 93 | }, 94 | Values: map[string]string{ 95 | "name_prefix": "demodb-", 96 | }, 97 | }, 98 | }, 99 | } 100 | 101 | for _, tC := range testCases { 102 | t.Run(tC.desc, func(t *testing.T) { 103 | // Read the Terraform JSON plan file 104 | p, err := terraformtest.ReadPlan(tC.planJSONPath) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | // Get the resourceSet (map of resources in the plan) 109 | gotRS := p.Resources 110 | // Test if the resource wanted is present in the plan 111 | if !gotRS.Contains(tC.wantResource) { 112 | // If does not contain, output the diff 113 | t.Error(gotRS.Diff()) 114 | } 115 | }) 116 | } 117 | 118 | // Set the total number of resources the plan must have 119 | wantNumResources := 40 120 | items := p.Resources.Resources 121 | 122 | // Test if number of resources in the plan is equal to number of resources wanted 123 | if len(items) != wantNumResources { 124 | t.Errorf("want %d resources in plan, got %d", wantNumResources, len(items)) 125 | } 126 | } 127 | ``` 128 | 129 | ### Test if plan is equal (have all the resources wanted) 130 | 131 | The difference between Contains and Equal is that Equal requires all resources 132 | to be declared in the slice of wanted Resource. If there's one item in the plan 133 | that doesn't exist in the resourceSet or vice-versa it fails. 134 | 135 | ```go 136 | func TestEqual(t *testing.T) { 137 | wantRS := []terraformtest.Resource{ 138 | { 139 | Address: "module.nomad_job.nomad_job.test_job", 140 | Metadata: map[string]string{ 141 | "type": "nomad_job", 142 | "name": "test_job", 143 | }, 144 | Values: map[string]string{ 145 | "name": "unit-test", 146 | "datacenters": `["dc1"]`, 147 | }, 148 | }, 149 | } 150 | 151 | p, err := terraformtest.ReadPlan("testdata/terraform.plan.json") 152 | if err != nil { 153 | t.Fatal(err) 154 | } 155 | gotRS := p.Resources 156 | 157 | if !terraformtest.Equal(wantRS, &gotRS) { 158 | t.Error(gotRS.Diff()) 159 | } 160 | } 161 | ``` 162 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thiagonache/terraformtest 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/google/go-cmp v0.5.5 7 | github.com/tidwall/gjson v1.6.8 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 2 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 3 | github.com/tidwall/gjson v1.6.8 h1:CTmXMClGYPAmln7652e69B7OLXfTi5ABcPPwjIWUv7w= 4 | github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= 5 | github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE= 6 | github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 7 | github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= 8 | github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 9 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 10 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 11 | -------------------------------------------------------------------------------- /terraformtest.go: -------------------------------------------------------------------------------- 1 | package terraformtest 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/tidwall/gjson" 10 | ) 11 | 12 | type loopControl struct { 13 | curDepth, maxDepth int 14 | curItemIndex, curItemSubKey string 15 | prevItemIndex string 16 | } 17 | 18 | // Plan is the main struct containing the Plan data 19 | type Plan struct { 20 | Data []byte 21 | loopControl loopControl 22 | Resources ResourceSet 23 | } 24 | 25 | type compDiff struct { 26 | items []compDiffItem 27 | } 28 | 29 | type compDiffItem struct { 30 | got, key, want string 31 | } 32 | 33 | // Resource represents a resource being tested 34 | type Resource struct { 35 | Address string 36 | Metadata map[string]string 37 | Values map[string]string 38 | } 39 | 40 | // ResourceSet stores the resources (items) and diff of the plan file. 41 | type ResourceSet struct { 42 | Resources map[string]map[string]map[string]string 43 | CompDiff compDiff 44 | } 45 | 46 | // ReadPlan takes the plan's file path in JSON format and returns a pointer to a 47 | // Plan object and an error. 48 | func ReadPlan(planPath string) (*Plan, error) { 49 | tf := &Plan{ 50 | loopControl: loopControl{maxDepth: 10}, 51 | Resources: ResourceSet{ 52 | Resources: map[string]map[string]map[string]string{}, 53 | CompDiff: compDiff{}, 54 | }, 55 | } 56 | 57 | f, err := os.Open(planPath) 58 | if err != nil { 59 | return tf, fmt.Errorf("cannot open file %s: %v", planPath, err) 60 | } 61 | defer f.Close() 62 | 63 | plan, err := io.ReadAll(f) 64 | if err != nil { 65 | return tf, fmt.Errorf("cannot read data from IO Reader: %v", err) 66 | } 67 | 68 | tf.Data = plan 69 | tf.ResourceSet() 70 | 71 | return tf, nil 72 | } 73 | 74 | // Diff iterates over CompDiff items to concatenate all errors by new line. 75 | func (rs ResourceSet) Diff() string { 76 | var stringDiff string 77 | for _, diff := range rs.CompDiff.items { 78 | stringDiff += fmt.Sprintf(`key %q: want %q, got %q\n`, diff.key, diff.want, diff.got) 79 | } 80 | return stringDiff 81 | } 82 | 83 | // ResourceSet transform the multi level json into one big object to make 84 | // queries easier. 85 | func (tfPlan *Plan) ResourceSet() { 86 | rootModule := gjson.GetBytes(tfPlan.Data, `planned_values.root_module|@pretty:{"sortKeys":true}`) 87 | rootModule.ForEach(tfPlan.transform) 88 | } 89 | 90 | func (tfPlan *Plan) transform(key, value gjson.Result) bool { 91 | if tfPlan.loopControl.curDepth > tfPlan.loopControl.maxDepth { 92 | fmt.Println("MaxDepth reached") 93 | return false 94 | } 95 | 96 | switch key.String() { 97 | case "resources": 98 | tfPlan.loopControl.prevItemIndex = "resources" 99 | tfPlan.loopControl.curDepth++ 100 | for _, child := range value.Array() { 101 | child.ForEach(tfPlan.transform) 102 | } 103 | case "child_modules": 104 | tfPlan.loopControl.prevItemIndex = "child_modules" 105 | tfPlan.loopControl.curDepth++ 106 | for _, child := range value.Array() { 107 | child.ForEach(tfPlan.transform) 108 | } 109 | case "values": 110 | tfPlan.loopControl.curItemSubKey = "Values" 111 | _, ok := tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex] 112 | if !ok { 113 | tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex] = map[string]map[string]string{} 114 | } 115 | tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex][tfPlan.loopControl.curItemSubKey] = map[string]string{} 116 | value.ForEach(tfPlan.transform) 117 | case "address": 118 | // We are only interested in addresses of resources 119 | if tfPlan.loopControl.prevItemIndex != "resources" { 120 | break 121 | } 122 | tfPlan.loopControl.curItemSubKey = "Metadata" 123 | tfPlan.loopControl.curItemIndex = value.String() 124 | _, ok := tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex] 125 | if !ok { 126 | tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex] = map[string]map[string]string{} 127 | } 128 | tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex][tfPlan.loopControl.curItemSubKey] = map[string]string{} 129 | 130 | default: 131 | value := normalizeItem(value.String()) 132 | tfPlan.Resources.Resources[tfPlan.loopControl.curItemIndex][tfPlan.loopControl.curItemSubKey][key.String()] = value 133 | } 134 | 135 | return true 136 | } 137 | 138 | func normalizeItem(item string) string { 139 | item = strings.ReplaceAll(item, "\n", "") 140 | item = strings.ReplaceAll(item, " ", "") 141 | return item 142 | } 143 | 144 | func (rs *ResourceSet) newCompDiffItem(key, want, got string) { 145 | item := compDiffItem{ 146 | got: got, 147 | key: key, 148 | want: want, 149 | } 150 | rs.CompDiff.items = append(rs.CompDiff.items, item) 151 | } 152 | 153 | // Contains check if a resource exist in the resourceSet. 154 | func (rs *ResourceSet) Contains(r Resource) bool { 155 | metadata, ok := rs.Resources[r.Address]["Metadata"] 156 | if !ok { 157 | rs.newCompDiffItem(r.Address, "exist", "nil") 158 | return false 159 | } 160 | for k, v := range r.Metadata { 161 | valueFound, ok := metadata[k] 162 | if !ok { 163 | rs.newCompDiffItem(k, "exist", "nil") 164 | return false 165 | } 166 | v = normalizeItem(v) 167 | if valueFound != v { 168 | rs.newCompDiffItem(k, v, valueFound) 169 | return false 170 | } 171 | } 172 | 173 | values, ok := rs.Resources[r.Address]["Values"] 174 | if !ok { 175 | rs.newCompDiffItem(r.Address, "exist", "nil") 176 | return false 177 | } 178 | for k, v := range r.Values { 179 | valueFound, ok := values[k] 180 | if !ok { 181 | rs.newCompDiffItem(k, "exist", "nil") 182 | return false 183 | } 184 | v = normalizeItem(v) 185 | if valueFound != v { 186 | rs.newCompDiffItem(k, v, valueFound) 187 | return false 188 | } 189 | } 190 | return true 191 | } 192 | 193 | // Equal check if all resources exist in the resourceSet and vice-versa. 194 | func Equal(resources []Resource, rs *ResourceSet) bool { 195 | resourcesRS := map[string]struct{}{} 196 | for _, r := range resources { 197 | resourcesRS[r.Address] = struct{}{} 198 | rsItem, ok := rs.Resources[r.Address] 199 | if !ok { 200 | rs.newCompDiffItem(r.Address, "exist in plan", "nil") 201 | return false 202 | } 203 | 204 | for k, v := range r.Metadata { 205 | valueFound, ok := rsItem["Metadata"][k] 206 | if !ok { 207 | rs.newCompDiffItem(r.Address, "exist in plan", "nil") 208 | return false 209 | } 210 | v = normalizeItem(v) 211 | if valueFound != v { 212 | rs.newCompDiffItem(k, v, valueFound) 213 | return false 214 | } 215 | } 216 | 217 | for k, v := range r.Values { 218 | valueFound, ok := rsItem["Values"][k] 219 | if !ok { 220 | rs.newCompDiffItem(r.Address, "exist in plan", "nil") 221 | return false 222 | } 223 | v = normalizeItem(v) 224 | if valueFound != v { 225 | rs.newCompDiffItem(k, v, valueFound) 226 | return false 227 | } 228 | } 229 | } 230 | 231 | for k := range rs.Resources { 232 | _, ok := resourcesRS[k] 233 | if !ok { 234 | rs.newCompDiffItem(k, "exist in resources", "nil") 235 | return false 236 | } 237 | } 238 | return true 239 | } 240 | -------------------------------------------------------------------------------- /terraformtest_test.go: -------------------------------------------------------------------------------- 1 | package terraformtest 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestReadPlanFile(t *testing.T) { 10 | t.Parallel() 11 | 12 | wantLen := 8716 13 | p, err := ReadPlan("testdata/terraform.plan.json") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | if wantLen != len(p.Data) { 19 | t.Errorf("want json size in bytes of %d but got %d", wantLen, len(p.Data)) 20 | } 21 | } 22 | 23 | func TestNumberResources(t *testing.T) { 24 | t.Parallel() 25 | 26 | wantNumResources := 40 27 | 28 | p, err := ReadPlan("testdata/terraform-aws-101.plan.json") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | items := p.Resources.Resources 33 | 34 | if len(items) != wantNumResources { 35 | t.Errorf("want %d resources in plan, got %d", wantNumResources, len(items)) 36 | } 37 | } 38 | 39 | func TestEqual(t *testing.T) { 40 | t.Parallel() 41 | 42 | wantRS := []Resource{ 43 | { 44 | Address: "module.nomad_job.nomad_job.test_job", 45 | Metadata: map[string]string{ 46 | "type": "nomad_job", 47 | "name": "test_job", 48 | }, 49 | Values: map[string]string{ 50 | "name": "unit-test", 51 | "datacenters": `["dc1"]`, 52 | }, 53 | }, 54 | } 55 | 56 | p, err := ReadPlan("testdata/terraform.plan.json") 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | gotRS := p.Resources 61 | 62 | if !Equal(wantRS, &gotRS) { 63 | t.Error(gotRS.Diff()) 64 | } 65 | } 66 | 67 | func TestContainsResource(t *testing.T) { 68 | testCases := []struct { 69 | desc, planJSONPath string 70 | wantResource Resource 71 | }{ 72 | { 73 | desc: "Test EIP", 74 | planJSONPath: "testdata/terraform-aws-101.plan.json", 75 | wantResource: Resource{ 76 | Address: "module.vpc.aws_eip.nat[0]", 77 | Metadata: map[string]string{ 78 | "type": "aws_eip", 79 | "name": "nat", 80 | }, 81 | Values: map[string]string{ 82 | "vpc": "true", 83 | "timeouts": "", 84 | }, 85 | }, 86 | }, 87 | { 88 | desc: "Test DB Subnet Group", 89 | planJSONPath: "testdata/terraform-aws-101.plan.json", 90 | wantResource: Resource{ 91 | Address: "module.db.module.db_subnet_group.aws_db_subnet_group.this[0]", 92 | Metadata: map[string]string{ 93 | "type": "aws_db_subnet_group", 94 | "name": "this", 95 | }, 96 | Values: map[string]string{ 97 | "name_prefix": "demodb-", 98 | }, 99 | }, 100 | }, 101 | } 102 | 103 | for _, tC := range testCases { 104 | t.Run(tC.desc, func(t *testing.T) { 105 | p, err := ReadPlan(tC.planJSONPath) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | gotRS := p.Resources 110 | if !gotRS.Contains(tC.wantResource) { 111 | t.Error(gotRS.Diff()) 112 | } 113 | }) 114 | } 115 | } 116 | 117 | func TestDiffExpected(t *testing.T) { 118 | testCases := []struct { 119 | desc, planJSONPath, wantDiff string 120 | resource Resource 121 | }{ 122 | { 123 | desc: "Test address doesn't exist", 124 | planJSONPath: "testdata/terraform-aws-101.plan.json", 125 | resource: Resource{ 126 | Address: "module.vpc.aws_eip.nat[3]", 127 | }, 128 | wantDiff: `key "module.vpc.aws_eip.nat[3]": want "exist", got "nil"\n`, 129 | }, 130 | { 131 | desc: "Test metadata doesn't exist", 132 | planJSONPath: "testdata/terraform-aws-101.plan.json", 133 | resource: Resource{ 134 | Address: "module.vpc.aws_eip.nat[0]", 135 | Metadata: map[string]string{ 136 | "typee": "aws_eip", 137 | }, 138 | }, 139 | wantDiff: `key "typee": want "exist", got "nil"\n`, 140 | }, 141 | { 142 | desc: "Test metadata wrong value", 143 | planJSONPath: "testdata/terraform-aws-101.plan.json", 144 | resource: Resource{ 145 | Address: "module.vpc.aws_eip.nat[0]", 146 | Metadata: map[string]string{ 147 | "type": "aws_db_subnet_group", 148 | }, 149 | }, 150 | wantDiff: `key "type": want "aws_db_subnet_group", got "aws_eip"\n`, 151 | }, 152 | { 153 | desc: "Test value does not exist", 154 | planJSONPath: "testdata/terraform-aws-101.plan.json", 155 | resource: Resource{ 156 | Address: "module.vpc.aws_eip.nat[0]", 157 | Values: map[string]string{ 158 | "abc": "xpto", 159 | }, 160 | }, 161 | wantDiff: `key "abc": want "exist", got "nil"\n`, 162 | }, 163 | { 164 | desc: "Test wrong value", 165 | planJSONPath: "testdata/terraform-aws-101.plan.json", 166 | resource: Resource{ 167 | Address: "module.vpc.aws_eip.nat[0]", 168 | Values: map[string]string{ 169 | "vpc": "false", 170 | }, 171 | }, 172 | wantDiff: `key "vpc": want "false", got "true"\n`, 173 | }, 174 | } 175 | for _, tC := range testCases { 176 | t.Run(tC.desc, func(t *testing.T) { 177 | p, err := ReadPlan(tC.planJSONPath) 178 | if err != nil { 179 | t.Fatal(err) 180 | } 181 | gotRS := p.Resources 182 | gotRS.Contains(tC.resource) 183 | gotDiff := gotRS.Diff() 184 | if !cmp.Equal(tC.wantDiff, gotDiff) { 185 | t.Error(cmp.Diff(tC.wantDiff, gotDiff)) 186 | } 187 | 188 | }) 189 | } 190 | } 191 | 192 | func TestEKSCluster(t *testing.T) { 193 | testCases := []struct { 194 | desc string 195 | planPath string 196 | want Resource 197 | }{ 198 | { 199 | desc: "EKS Cluster", 200 | planPath: "./testdata/ekspoc.plan.json", 201 | want: Resource{ 202 | Address: "module.eks.aws_eks_cluster.this[0]", 203 | Metadata: map[string]string{ 204 | "type": "aws_eks_cluster", 205 | "name": "this", 206 | "index": "0", 207 | }, 208 | Values: map[string]string{ 209 | "name": "argocd-playground", 210 | "encryption_config": `[ 211 | { "provider": [{}], "resources": ["secrets"] } 212 | ]`, 213 | }, 214 | }, 215 | }, 216 | } 217 | for _, tC := range testCases { 218 | t.Run(tC.desc, func(t *testing.T) { 219 | tfPlan, err := ReadPlan(tC.planPath) 220 | if err != nil { 221 | t.Fatalf("%v", err) 222 | } 223 | gotRS := tfPlan.Resources 224 | if !gotRS.Contains(tC.want) { 225 | t.Error(gotRS.Diff()) 226 | } 227 | }) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /testdata/terraform.plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "format_version": "0.1", 3 | "terraform_version": "0.13.5", 4 | "variables": { 5 | "job_count": { 6 | "value": 2 7 | }, 8 | "job_name": { 9 | "value": "unit-test" 10 | }, 11 | "nomad_addr": { 12 | "value": "http://127.0.0.1:4646" 13 | }, 14 | "template_name": { 15 | "value": "example.nomad.tpl" 16 | } 17 | }, 18 | "planned_values": { 19 | "root_module": { 20 | "child_modules": [ 21 | { 22 | "resources": [ 23 | { 24 | "address": "module.nomad_job.nomad_job.test_job", 25 | "mode": "managed", 26 | "type": "nomad_job", 27 | "name": "test_job", 28 | "provider_name": "registry.terraform.io/hashicorp/nomad", 29 | "schema_version": 0, 30 | "values": { 31 | "datacenters": ["dc1"], 32 | "deregister_on_destroy": true, 33 | "deregister_on_id_change": true, 34 | "detach": true, 35 | "hcl2": [ 36 | { 37 | "allow_fs": true, 38 | "enabled": true 39 | } 40 | ], 41 | "jobspec": "job \"unit-test\" {\n datacenters = [\"dc1\"]\n type = \"service\"\n update {\n max_parallel = 1\n min_healthy_time = \"10s\"\n healthy_deadline = \"3m\"\n progress_deadline = \"10m\"\n auto_revert = false\n canary = 0\n }\n migrate {\n max_parallel = 1\n health_check = \"checks\"\n min_healthy_time = \"10s\"\n healthy_deadline = \"5m\"\n }\n group \"cache\" {\n count = 2\n network {\n port \"db\" {\n to = 6379\n }\n }\n service {\n name = \"redis-cache\"\n tags = [\"global\", \"cache\"]\n port = \"db\"\n }\n restart {\n # The number of attempts to run the job within the specified interval.\n attempts = 2\n interval = \"30m\"\n delay = \"15s\"\n mode = \"fail\"\n }\n ephemeral_disk {\n size = 10\n }\n task \"redis\" {\n driver = \"docker\"\n config {\n image = \"redis:3.2\"\n\n ports = [\"db\"]\n }\n resources {\n cpu = 500 # 500 MHz\n memory = 256 # 256MB\n }\n }\n }\n}\n", 42 | "json": null, 43 | "name": "unit-test", 44 | "namespace": "default", 45 | "policy_override": null, 46 | "purge_on_destroy": null, 47 | "task_groups": [ 48 | { 49 | "count": 2, 50 | "name": "cache", 51 | "task": [ 52 | { 53 | "driver": "docker", 54 | "name": "redis", 55 | "volume_mounts": null 56 | } 57 | ], 58 | "volumes": null 59 | } 60 | ], 61 | "type": "service" 62 | } 63 | } 64 | ], 65 | "address": "module.nomad_job" 66 | } 67 | ] 68 | } 69 | }, 70 | "resource_changes": [ 71 | { 72 | "address": "module.nomad_job.nomad_job.test_job", 73 | "module_address": "module.nomad_job", 74 | "mode": "managed", 75 | "type": "nomad_job", 76 | "name": "test_job", 77 | "provider_name": "registry.terraform.io/hashicorp/nomad", 78 | "change": { 79 | "actions": ["create"], 80 | "before": null, 81 | "after": { 82 | "datacenters": ["dc1"], 83 | "deregister_on_destroy": true, 84 | "deregister_on_id_change": true, 85 | "detach": true, 86 | "hcl2": [ 87 | { 88 | "allow_fs": true, 89 | "enabled": true 90 | } 91 | ], 92 | "jobspec": "job \"unit-test\" {\n datacenters = [\"dc1\"]\n type = \"service\"\n update {\n max_parallel = 1\n min_healthy_time = \"10s\"\n healthy_deadline = \"3m\"\n progress_deadline = \"10m\"\n auto_revert = false\n canary = 0\n }\n migrate {\n max_parallel = 1\n health_check = \"checks\"\n min_healthy_time = \"10s\"\n healthy_deadline = \"5m\"\n }\n group \"cache\" {\n count = 2\n network {\n port \"db\" {\n to = 6379\n }\n }\n service {\n name = \"redis-cache\"\n tags = [\"global\", \"cache\"]\n port = \"db\"\n }\n restart {\n # The number of attempts to run the job within the specified interval.\n attempts = 2\n interval = \"30m\"\n delay = \"15s\"\n mode = \"fail\"\n }\n ephemeral_disk {\n size = 10\n }\n task \"redis\" {\n driver = \"docker\"\n config {\n image = \"redis:3.2\"\n\n ports = [\"db\"]\n }\n resources {\n cpu = 500 # 500 MHz\n memory = 256 # 256MB\n }\n }\n }\n}\n", 93 | "json": null, 94 | "name": "unit-test", 95 | "namespace": "default", 96 | "policy_override": null, 97 | "purge_on_destroy": null, 98 | "task_groups": [ 99 | { 100 | "count": 2, 101 | "name": "cache", 102 | "task": [ 103 | { 104 | "driver": "docker", 105 | "name": "redis", 106 | "volume_mounts": null 107 | } 108 | ], 109 | "volumes": null 110 | } 111 | ], 112 | "type": "service" 113 | }, 114 | "after_unknown": { 115 | "allocation_ids": true, 116 | "datacenters": [false], 117 | "deployment_id": true, 118 | "deployment_status": true, 119 | "hcl2": [{}], 120 | "id": true, 121 | "modify_index": true, 122 | "region": true, 123 | "task_groups": [ 124 | { 125 | "meta": true, 126 | "task": [ 127 | { 128 | "meta": true 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | } 135 | } 136 | ], 137 | "configuration": { 138 | "provider_config": { 139 | "module.nomad_job:nomad": { 140 | "name": "nomad", 141 | "module_address": "module.nomad_job", 142 | "expressions": { 143 | "address": { 144 | "references": ["var.nomad_addr"] 145 | } 146 | } 147 | }, 148 | "nomad": { 149 | "name": "nomad", 150 | "expressions": { 151 | "address": { 152 | "references": ["var.nomad_addr"] 153 | } 154 | } 155 | } 156 | }, 157 | "root_module": { 158 | "module_calls": { 159 | "nomad_job": { 160 | "source": "../../", 161 | "expressions": { 162 | "job_count": { 163 | "references": ["var.job_count"] 164 | }, 165 | "job_name": { 166 | "references": ["var.job_name"] 167 | }, 168 | "nomad_addr": { 169 | "references": ["var.nomad_addr"] 170 | }, 171 | "template_name": { 172 | "references": ["var.template_name"] 173 | } 174 | }, 175 | "module": { 176 | "resources": [ 177 | { 178 | "address": "nomad_job.test_job", 179 | "mode": "managed", 180 | "type": "nomad_job", 181 | "name": "test_job", 182 | "provider_config_key": "nomad_job:nomad", 183 | "expressions": { 184 | "hcl2": [ 185 | { 186 | "allow_fs": { 187 | "constant_value": true 188 | }, 189 | "enabled": { 190 | "constant_value": true 191 | } 192 | } 193 | ], 194 | "jobspec": { 195 | "references": [ 196 | "path.module", 197 | "var.template_name", 198 | "var.job_name", 199 | "var.job_count" 200 | ] 201 | } 202 | }, 203 | "schema_version": 0 204 | } 205 | ], 206 | "variables": { 207 | "job_count": { 208 | "default": 1, 209 | "description": "Number of copies to be spawn" 210 | }, 211 | "job_name": { 212 | "default": "example", 213 | "description": "Name for job being created" 214 | }, 215 | "nomad_addr": { 216 | "default": "http://127.0.0.1:4646", 217 | "description": "Nomad endpoint to connect to" 218 | }, 219 | "template_name": { 220 | "default": "example.nomad.tpl", 221 | "description": "Template file inside of templates directory" 222 | } 223 | } 224 | } 225 | } 226 | }, 227 | "variables": { 228 | "job_count": { 229 | "description": "Number of copies to be spawn" 230 | }, 231 | "job_name": { 232 | "description": "Name for job being created" 233 | }, 234 | "nomad_addr": { 235 | "description": "Nomad endpoint to connect to" 236 | }, 237 | "template_name": { 238 | "description": "Template file inside of templates directory" 239 | } 240 | } 241 | } 242 | } 243 | } 244 | --------------------------------------------------------------------------------