├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile.goreleaser ├── LICENSE ├── README.md ├── builder ├── build.go └── build_funcs.go ├── cmd ├── apply.go ├── build.go ├── dev.go ├── drift.go ├── init.go ├── messages.go ├── parse.go ├── plan.go ├── root.go ├── show.go └── version.go ├── docs ├── .pages ├── CNAME ├── assets │ ├── favicon.png │ ├── images │ │ ├── terraplate-file-structure.png │ │ ├── tp-dev-mode.png │ │ └── tutorials │ │ │ └── drift-slack-notification │ │ │ ├── slack-drift.png │ │ │ ├── slack-error.png │ │ │ └── slack-no-drift.png │ └── logo │ │ ├── terraplate-short-white.svg │ │ ├── terraplate-verifa.svg │ │ └── terraplate-white.svg ├── commands │ ├── .pages │ ├── terraplate.md │ ├── terraplate_apply.md │ ├── terraplate_build.md │ ├── terraplate_dev.md │ ├── terraplate_drift.md │ ├── terraplate_init.md │ ├── terraplate_parse.md │ ├── terraplate_plan.md │ ├── terraplate_show.md │ └── terraplate_version.md ├── examples.md ├── faq │ ├── terraplate-vs-terragrunt.md │ └── terraplate-vs-tf-workspaces.md ├── gen.go ├── index.md ├── installation.md ├── project-status.md ├── quick-start.md ├── reference │ ├── .pages │ ├── complete.md │ └── terrafile.md ├── stylesheets │ └── theme.css └── tutorials │ ├── .pages │ ├── dev-mode.md │ ├── drift-slack-notifications.md │ └── multiple-root-modules.md ├── examples ├── README.md ├── aws │ ├── stack │ │ └── terraplate.hcl │ ├── templates │ │ ├── backend.tmpl │ │ ├── common.tmpl │ │ └── providers.tmpl │ └── terraplate.hcl ├── complete │ ├── templates │ │ └── example.tmpl │ └── terraplate.hcl ├── config │ ├── README.md │ ├── clusters │ │ ├── cluster-a │ │ │ ├── cluster.tp.hcl │ │ │ ├── prod │ │ │ │ └── terraplate.hcl │ │ │ └── staging │ │ │ │ └── terraplate.hcl │ │ └── cluster-b │ │ │ ├── cluster.tp.hcl │ │ │ ├── prod │ │ │ └── terraplate.hcl │ │ │ └── staging │ │ │ └── terraplate.hcl │ ├── config.yaml │ ├── templates │ │ ├── cluster.tmpl │ │ └── config.tmpl │ └── terraplate.hcl ├── multiple-providers │ └── terraplate.hcl ├── nested │ ├── eu-west-1 │ │ ├── dev │ │ │ ├── env.tp.hcl │ │ │ └── file │ │ │ │ └── terraplate.hcl │ │ ├── prod │ │ │ ├── env.tp.hcl │ │ │ └── file │ │ │ │ └── terraplate.hcl │ │ ├── region.tp.hcl │ │ └── templates │ │ │ └── override.tmpl │ ├── templates │ │ ├── backend.tmpl │ │ ├── main.tmpl │ │ ├── override.tmpl │ │ └── providers.tmpl │ └── terraplate.hcl ├── simple │ ├── dev │ │ └── terraplate.hcl │ ├── prod │ │ └── terraplate.hcl │ ├── templates │ │ ├── backend.tmpl │ │ ├── main.tmpl │ │ └── providers.tmpl │ └── terraplate.hcl └── yaml │ ├── .gitignore │ ├── templates │ └── config.yaml.tmpl │ └── terraplate.hcl ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── mkdocs.yml ├── notify ├── data.go ├── notify.go ├── notify_test.go ├── repo.go ├── repo_test.go ├── slack.go └── templates │ └── slack.tmpl ├── parser ├── data.go ├── evalctx.go ├── exec.go ├── parser.go ├── parser_test.go ├── template.go ├── terraconfig.go ├── terrafile.go └── testdata │ ├── nested │ └── 1 │ │ ├── 2 │ │ ├── terraplate.hcl │ │ └── terraplate.tf │ │ └── terraplate.hcl │ ├── override │ ├── a │ │ ├── terraplate.hcl │ │ └── terraplate.tf │ └── b │ │ ├── terraplate.hcl │ │ └── terraplate.tf │ ├── terraplate.hcl │ └── testTemplates │ ├── nested │ ├── config.tp.tf │ ├── terraplate.hcl │ └── terraplate.tf │ ├── templates │ └── config.tmpl │ └── terraplate.hcl ├── requirements.txt ├── runner ├── cmds.go ├── drift.go ├── level.go ├── opts.go ├── rootmodule.go ├── run.go ├── runner.go ├── styles.go ├── task.go ├── test.sh ├── testData │ └── empty.tf └── worker.go ├── tui ├── entryui │ ├── commands.go │ ├── keys.go │ ├── model.go │ └── styles.go ├── model.go ├── modulesui │ ├── columns.go │ ├── commands.go │ ├── item.go │ ├── keys.go │ ├── model.go │ ├── state.go │ └── styles.go └── state.go └── tutorials ├── multiple-root-modules-finished ├── README.md ├── local │ ├── dev │ │ ├── main.tf │ │ └── terraplate.hcl │ ├── prod │ │ ├── main.tf │ │ └── terraplate.hcl │ └── terraplate.hcl ├── templates │ ├── backend_local.tmpl │ └── provider_local.tmpl └── terraplate.hcl └── multiple-root-modules ├── README.md ├── backend.tf ├── main.tf ├── terraplate.hcl └── versions.tf /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # CI Workflow 2 | 3 | on: 4 | # run this workflow on any branch or a PR 5 | push: 6 | branches: 7 | - "**" 8 | pull_request: 9 | branches: 10 | - "**" 11 | 12 | name: ci 13 | jobs: 14 | build-test: 15 | strategy: 16 | # matrix lets us expand our range of OSs / go 17 | matrix: 18 | go-version: [1.17] 19 | os: [ubuntu-latest] 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - name: Install Go 23 | # Skip install if testing action locally using act 24 | if: ${{ !env.ACT }} 25 | uses: actions/setup-go@v2 26 | with: 27 | go-version: ${{ matrix.go-version }} 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | - name: cache go mod 31 | # Skip caching if testing action locally using act 32 | if: ${{ !env.ACT }} 33 | uses: actions/cache@v2 34 | with: 35 | path: ~/go/pkg/mod 36 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 37 | restore-keys: | 38 | ${{ runner.os }}-go- 39 | - name: Test 40 | run: go test -v -failfast ./... 41 | # Build with GoReleaser (but don't release!). 42 | # This verifies that the GoReleaser config is valid and can build 43 | - name: Run GoReleaser 44 | uses: goreleaser/goreleaser-action@v2 45 | with: 46 | version: latest 47 | args: build --snapshot --rm-dist 48 | mkdocs: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v3 52 | - name: Set up Python 53 | uses: actions/setup-python@v4 54 | with: 55 | python-version: "3.x" 56 | - name: Install dependencies 57 | run: pip install -r requirements.txt 58 | - name: Build 59 | run: mkdocs build 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # Release Workflow 2 | 3 | name: release 4 | on: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.17 21 | - name: Login to Docker Hub 22 | uses: docker/login-action@v1 23 | with: 24 | username: ${{ secrets.DOCKERHUB_USERNAME }} 25 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v2 28 | with: 29 | version: latest 30 | args: release 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 33 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 34 | mkdocs: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Set up Python 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: "3.x" 42 | - name: Install dependencies 43 | run: pip install -r requirements.txt 44 | - name: Build 45 | run: mkdocs build 46 | - name: Publish 47 | run: mkdocs gh-deploy --force 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.DS_Store 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | 18 | # Ignore build directory 19 | build/ 20 | 21 | # Ignore mkdocs build directory 22 | site/ 23 | 24 | # Ignore the generated files (.tf and .tfvars) 25 | examples/**/tfplan 26 | examples/**/*.* 27 | !examples/**/.gitignore 28 | !examples/**/*.yaml 29 | !examples/**/terraplate.hcl 30 | !examples/**/*.md 31 | !examples/**/templates/* 32 | 33 | # Ignore files in tutorials 34 | tutorials/**/tfplan 35 | tutorials/**/*.* 36 | !tutorials/**/.gitignore 37 | !tutorials/**/*.yaml 38 | !tutorials/**/terraplate.hcl 39 | !tutorials/**/*.md 40 | !tutorials/**/templates/* 41 | # Ignore manually written terraform files 42 | !tutorials/**/*.tf 43 | # But... do ignore the generated files 44 | tutorials/**/*.tp.tf 45 | tutorials/**/terraplate.tf 46 | 47 | # Ignore any default plans that exist in a testData directory 48 | **/testData/**/tfplan 49 | 50 | # goreleaser build directory 51 | dist/ 52 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: terraplate 2 | before: 3 | hooks: 4 | - go mod tidy 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | ldflags: 13 | - "-s -w" 14 | - "-X github.com/verifa/terraplate/cmd.version={{.Version}}" 15 | - "-X github.com/verifa/terraplate/cmd.commit={{.Commit}}" 16 | - "-X github.com/verifa/terraplate/cmd.date={{.Date}}" 17 | checksum: 18 | name_template: "checksums.txt" 19 | snapshot: 20 | name_template: "{{ incpatch .Version }}-next" 21 | changelog: 22 | sort: asc 23 | 24 | brews: 25 | - name: terraplate 26 | tap: 27 | owner: verifa 28 | name: homebrew-tap 29 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 30 | folder: Formula 31 | homepage: "https://github.com/verifa/terraplate" 32 | description: "Terraplate" 33 | test: | 34 | system "#{bin}/terraplate version" 35 | 36 | dockers: 37 | - goos: linux 38 | goarch: amd64 39 | skip_push: "false" 40 | use: buildx 41 | dockerfile: Dockerfile.goreleaser 42 | image_templates: 43 | - "verifa/{{ .ProjectName }}:latest" 44 | - "verifa/{{ .ProjectName }}:{{ .Tag }}" 45 | - "verifa/{{ .ProjectName }}:{{ .Major }}" 46 | - "verifa/{{ .ProjectName }}:{{ .Major }}.{{ .Minor }}" 47 | - "verifa/{{ .ProjectName }}:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" 48 | build_flag_templates: 49 | - "--pull" 50 | 51 | release: 52 | github: 53 | owner: verifa 54 | name: terraplate 55 | prerelease: auto 56 | disable: false 57 | draft: false 58 | -------------------------------------------------------------------------------- /Dockerfile.goreleaser: -------------------------------------------------------------------------------- 1 | # This Dockerfile is written for use with goreleaser 2 | FROM gcr.io/distroless/base-debian10 3 | 4 | # Copy the static executable built by goreleaser 5 | COPY terraplate /terraplate 6 | 7 | # Run as unprivileged user 8 | USER nonroot:nonroot 9 | 10 | ENTRYPOINT ["/terraplate"] 11 | CMD ["--help"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Terraplate logo 2 | 3 | > DRY Terraform with Go Templates 4 | 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/verifa/terraplate)](https://goreportcard.com/report/github.com/verifa/terraplate) 6 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 7 | 8 | Terraplate is a thin wrapper around [Terraform](https://terraform.io) to keep things DRY and improve the developer experience. 9 | 10 | ## How it works 11 | 12 | Terraplate traverses up and down from the working directory detecting Terraplate files (AKA "Terrafiles"), treating the Terrafiles without child Terrafiles as [Root Modules](https://www.terraform.io/language/modules#the-root-module) (i.e. if a Terrafile does not have any children, it's considered a Root Module where Terraform should be run). 13 | 14 | Terraplate builds Terraform files based on your provided templates (using Go Templates). 15 | Define your Terraform snippets once, and reuse them with Go Templates to substitute the values based on the different root modules. 16 | 17 | Terraplate example file structure 18 | 19 | The built files are completely normal Terraform files that should be **version controlled** and can be applied either via the `terraform` CLI or using the `terraplate` CLI. 20 | This way you can focus on writing your Terraform code that creates resources, and let Terraplate handle the boilerplate (like backend, providers, configuration, etc) based on your provided templates. 21 | 22 | The goal of Terraplate is to not do any magic: just plain (but DRY) Terraform, which means you can bring your own tools for static analysis, security, policies, testing and deployment. 23 | 24 | The `terraplate` CLI allows you to run Terraform across all your Root Modules and provide a summary of plans. 25 | 26 | [![terraplate-asciicast](https://asciinema.org/a/502295.svg)](https://asciinema.org/a/502295) 27 | 28 | Terraplate is a thin wrapper around Terraform aimed at reducing the amount of duplicate code used when working with multiple different Terraform [root modules](https://www.terraform.io/language/modules#the-root-module). 29 | 30 | ## Documentation 31 | 32 | Please check the [Documentation](https://terraplate.verifa.io) 33 | 34 | ## Project Status 35 | 36 | Terraplate is **stable** and being used in production. There should be no breaking changes unless there is a new major version. 37 | 38 | Terraplate does not have a cache, cleanup functionality or overwrite protection. 39 | It's quite dumb, by design, so please be careful that you do not overwrite existing files. 40 | 41 | ## Examples 42 | 43 | See the [examples](./examples) folder. 44 | 45 | ## License 46 | 47 | This code is released under the [Apache-2.0 License](./LICENSE). 48 | -------------------------------------------------------------------------------- /builder/build.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "sort" 10 | 11 | "github.com/fatih/color" 12 | "github.com/hashicorp/hcl/v2/hclwrite" 13 | "github.com/verifa/terraplate/parser" 14 | "github.com/zclconf/go-cty/cty" 15 | "github.com/zclconf/go-cty/cty/gocty" 16 | ) 17 | 18 | var errorColor = color.New(color.FgRed, color.Bold) 19 | 20 | // BuildTerrafile takes an input Terrafile and builds it, writing any output 21 | // to the provided io.Writer 22 | func BuildTerrafile(tf *parser.Terrafile, out io.Writer) error { 23 | if err := buildTerraplate(tf, out); err != nil { 24 | buildErr := fmt.Errorf("building Terraplate Terraform file: %w", err) 25 | fmt.Fprintf(out, "\n%s: %v", errorColor.Sprint("Error"), buildErr) 26 | return buildErr 27 | } 28 | 29 | if err := buildTemplates(tf, out); err != nil { 30 | buildErr := fmt.Errorf("building templates: %w", err) 31 | fmt.Fprintf(out, "\n%s: %v", errorColor.Sprint("Error"), buildErr) 32 | return buildErr 33 | } 34 | return nil 35 | } 36 | 37 | // buildTemplates builds the templates associated with the given terrafile 38 | func buildTemplates(tf *parser.Terrafile, out io.Writer) error { 39 | for _, tmpl := range tf.Templates { 40 | target := filepath.Join(tf.Dir, tmpl.Target) 41 | fmt.Fprintf(out, "Building template %s to %s\n", tmpl.Name, target) 42 | 43 | data, dataErr := tf.BuildData() 44 | if dataErr != nil { 45 | return fmt.Errorf("getting build data for %s: %w", tf.Path, dataErr) 46 | } 47 | if tmpl.ConditionAttr != "" { 48 | condition, condErr := tmpl.Condition(data) 49 | if condErr != nil { 50 | return fmt.Errorf("evaluating condition for %s: %w", tf.Path, condErr) 51 | } 52 | if !condition { 53 | continue 54 | } 55 | } 56 | 57 | content := defaultTemplateHeader(tf, tmpl) + tmpl.Contents 58 | if err := parser.TemplateWrite(data, tmpl.Name, content, target); err != nil { 59 | return fmt.Errorf("creating template %s in terrafile %s: %w", tmpl.Name, tf.RelativePath(), err) 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | // buildTerraplate builds the terraplate terraform file which contains the 66 | // variables (with defaults) and terraform block 67 | func buildTerraplate(terrafile *parser.Terrafile, out io.Writer) error { 68 | 69 | path := filepath.Join(terrafile.Dir, "terraplate.tf") 70 | fmt.Fprintf(out, "Building terraplate.tf file to %s\n", path) 71 | 72 | // Create the Terraform file 73 | tfFile := hclwrite.NewEmptyFile() 74 | 75 | // Write the terraform{} block 76 | tfBlock := hclwrite.NewBlock("terraform", []string{}) 77 | // Set the required version if it was given 78 | if reqVer := terrafile.TerraformBlock.RequiredVersion; reqVer != "" { 79 | tfBlock.Body().SetAttributeValue("required_version", cty.StringVal(reqVer)) 80 | tfBlock.Body().AppendNewline() 81 | } 82 | provBlock := hclwrite.NewBlock("required_providers", []string{}) 83 | // We need to iterate over the required providers in order to avoid lots of 84 | // changes each time. 85 | // Iterate over the sorted keys and then extract the value for that key 86 | provMap := terrafile.TerraformBlock.RequiredProviders() 87 | if len(provMap) > 0 { 88 | for _, name := range sortedMapKeys(provMap) { 89 | value := provMap[name] 90 | ctyType, typeErr := gocty.ImpliedType(value) 91 | if typeErr != nil { 92 | return fmt.Errorf("implying required provider to cty type for provider %s: %w", name, typeErr) 93 | } 94 | ctyValue, ctyErr := gocty.ToCtyValue(value, ctyType) 95 | if ctyErr != nil { 96 | return fmt.Errorf("converting required provider to cty value for provider %s: %w", name, ctyErr) 97 | } 98 | provBlock.Body().SetAttributeValue(name, ctyValue) 99 | } 100 | tfBlock.Body().AppendBlock(provBlock) 101 | } 102 | // If body is not empty, write the terraform block 103 | if isBodyEmpty(tfBlock.Body()) { 104 | tfFile.Body().AppendBlock(tfBlock) 105 | tfFile.Body().AppendNewline() 106 | } 107 | 108 | // 109 | // Write the locals {} block 110 | // 111 | localsMap := terrafile.Locals() 112 | localsBlock := hclwrite.NewBlock("locals", nil) 113 | for _, name := range sortedMapKeys(localsMap) { 114 | value := localsMap[name] 115 | localsBlock.Body().SetAttributeValue(name, value) 116 | } 117 | // If locals map is not empty, write the locals block to the terraplate file 118 | if len(localsMap) > 0 { 119 | tfFile.Body().AppendBlock(localsBlock) 120 | tfFile.Body().AppendNewline() 121 | } 122 | 123 | // 124 | // Write the variables {} block 125 | // 126 | // We need to iterate over the variables in order to avoid lots of 127 | // changes each time. 128 | // Iterate over the sorted keys and then extract the value for that key 129 | varMap := terrafile.Variables() 130 | for _, name := range sortedMapKeys(varMap) { 131 | value := varMap[name] 132 | varBlock := hclwrite.NewBlock("variable", []string{name}) 133 | varBlock.Body().SetAttributeValue("default", value) 134 | tfFile.Body().AppendBlock(varBlock) 135 | tfFile.Body().AppendNewline() 136 | } 137 | 138 | // Create and write the file 139 | file, createErr := os.Create(path) 140 | if createErr != nil { 141 | return fmt.Errorf("creating file %s: %w", path, createErr) 142 | } 143 | 144 | contents := tfFile.Bytes() 145 | header := []byte(defaultTerraplateHeader(terrafile)) 146 | contents = append(header, contents...) 147 | 148 | defer file.Close() 149 | if _, writeErr := file.Write(hclwrite.Format(hclwrite.Format(contents))); writeErr != nil { 150 | return fmt.Errorf("writing file %s: %w", path, writeErr) 151 | } 152 | return nil 153 | } 154 | 155 | func defaultTemplateHeader(tf *parser.Terrafile, tmpl *parser.TerraTemplate) string { 156 | return fmt.Sprintf(`# 157 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 158 | # 159 | # Terrafile: %s 160 | # Template: %s 161 | 162 | `, tf.RelativePath(), tmpl.Name) 163 | } 164 | 165 | func defaultTerraplateHeader(tf *parser.Terrafile) string { 166 | return fmt.Sprintf(`# 167 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 168 | # 169 | # Terrafile: %s 170 | 171 | `, tf.RelativePath()) 172 | } 173 | 174 | // sortedMapKeys takes an input map and returns its keys sorted by alphabetical order 175 | func sortedMapKeys(v interface{}) []string { 176 | rv := reflect.ValueOf(v) 177 | if rv.Type().Kind() != reflect.Map { 178 | panic(fmt.Sprintf("cannot sort map keys of non-map type %s", rv.Type().String())) 179 | } 180 | rvKeys := rv.MapKeys() 181 | keys := make([]string, 0, len(rvKeys)) 182 | for _, key := range rvKeys { 183 | keys = append(keys, key.String()) 184 | } 185 | sort.Strings(keys) 186 | return keys 187 | } 188 | 189 | func isBodyEmpty(body *hclwrite.Body) bool { 190 | return len(body.Attributes()) > 0 || len(body.Blocks()) > 0 191 | } 192 | -------------------------------------------------------------------------------- /builder/build_funcs.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "fmt" 5 | "text/template" 6 | 7 | "github.com/hashicorp/hcl/v2/hclwrite" 8 | "github.com/zclconf/go-cty/cty" 9 | ) 10 | 11 | func builtinFuncs() template.FuncMap { 12 | return template.FuncMap{ 13 | "ctyValueToString": ctyValueToString, 14 | } 15 | } 16 | 17 | func ctyValueToString(val cty.Value) string { 18 | toks := hclwrite.TokensForValue(val) 19 | return fmt.Sprintf("%s\n", hclwrite.Format(toks.Bytes())) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/apply.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/verifa/terraplate/parser" 23 | "github.com/verifa/terraplate/runner" 24 | ) 25 | 26 | var applyJobs int 27 | 28 | // applyCmd represents the apply command 29 | var applyCmd = &cobra.Command{ 30 | Use: "apply", 31 | Short: "Runs terraform apply on all subdirectories", 32 | Long: `Runs terraform apply on all subdirectories.`, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | config, err := parser.Parse(&config.ParserConfig) 35 | if err != nil { 36 | return fmt.Errorf("parsing terraplate: %w", err) 37 | } 38 | r := runner.Run(config, runner.RunApply(), runner.Jobs(applyJobs), runner.ExtraArgs(args)) 39 | 40 | // Print log 41 | fmt.Println(r.Log(runner.OutputLevelAll)) 42 | 43 | return r.Errors() 44 | }, 45 | } 46 | 47 | func init() { 48 | RootCmd.AddCommand(applyCmd) 49 | 50 | applyCmd.Flags().IntVarP(&applyJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") 51 | } 52 | -------------------------------------------------------------------------------- /cmd/build.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/verifa/terraplate/parser" 23 | "github.com/verifa/terraplate/runner" 24 | ) 25 | 26 | var doValidate bool 27 | 28 | // buildCmd represents the build command 29 | var buildCmd = &cobra.Command{ 30 | Use: "build", 31 | Short: "Build Terraform files based your Terrafiles", 32 | Long: `Build (or generate) the Terraform files. 33 | 34 | For each Terrafile that is detected, build the Terraform files using the 35 | templates and configurations detected.`, 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | config, err := parser.Parse(&config.ParserConfig) 38 | if err != nil { 39 | return fmt.Errorf("parsing terraplate: %w", err) 40 | } 41 | 42 | runOpts := []func(r *runner.TerraRunOpts){ 43 | runner.RunBuild(), 44 | } 45 | if doValidate { 46 | runOpts = append(runOpts, runner.RunValidate()) 47 | } 48 | runOpts = append(runOpts, runner.ExtraArgs(args)) 49 | r := runner.Run(config, runOpts...) 50 | 51 | fmt.Println(r.Log(runner.OutputLevelAll)) 52 | fmt.Println(r.Summary(runner.OutputLevelAll)) 53 | 54 | return r.Errors() 55 | }, 56 | } 57 | 58 | func init() { 59 | RootCmd.AddCommand(buildCmd) 60 | buildCmd.Flags().BoolVar(&doValidate, "validate", false, "Validate (requires init) each root module after build") 61 | } 62 | -------------------------------------------------------------------------------- /cmd/dev.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "io" 21 | "os" 22 | 23 | tea "github.com/charmbracelet/bubbletea" 24 | "github.com/spf13/cobra" 25 | "github.com/verifa/terraplate/parser" 26 | "github.com/verifa/terraplate/runner" 27 | "github.com/verifa/terraplate/tui" 28 | ) 29 | 30 | // devCmd represents the plan command 31 | var devCmd = &cobra.Command{ 32 | Use: "dev", 33 | Short: "Enters dev mode which launches a Terminal UI for Terraplate", 34 | Long: `Enters dev mode which launches a Terminal UI for building and running Terraplate root modules.`, 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | // Parse 37 | config, err := parser.Parse(&config.ParserConfig) 38 | if err != nil { 39 | return fmt.Errorf("parsing terraplate: %w", err) 40 | } 41 | 42 | // Dev mode 43 | fmt.Print(devStartMessage) 44 | runOpts := []func(r *runner.TerraRunOpts){ 45 | runner.RunBuild(), 46 | runner.RunPlan(), 47 | runner.RunShowPlan(), 48 | runner.Jobs(planJobs), 49 | // Discard any output from the runner itself. 50 | // This does not discard the Terraform output. 51 | runner.Output(io.Discard), 52 | } 53 | runOpts = append(runOpts, runner.ExtraArgs(args)) 54 | runner := runner.New(config, runOpts...) 55 | 56 | p := tea.NewProgram( 57 | tui.New(runner), 58 | tea.WithAltScreen(), 59 | tea.WithMouseCellMotion(), 60 | ) 61 | if err := p.Start(); err != nil { 62 | fmt.Printf("Alas, there's been an error: %v", err) 63 | os.Exit(1) 64 | } 65 | return nil 66 | }, 67 | } 68 | 69 | func init() { 70 | RootCmd.AddCommand(devCmd) 71 | devCmd.Flags().IntVarP(&planJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") 72 | } 73 | -------------------------------------------------------------------------------- /cmd/drift.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/verifa/terraplate/notify" 23 | "github.com/verifa/terraplate/parser" 24 | "github.com/verifa/terraplate/runner" 25 | ) 26 | 27 | var ( 28 | notifyResultsUrl string 29 | notifyType string 30 | notifyFilter string 31 | notifySlackConfig = notify.DefaultSlackConfig() 32 | 33 | repo notify.Repo 34 | ) 35 | 36 | var driftCmd = &cobra.Command{ 37 | Use: "drift", 38 | Short: "Detect drift in your infrastructure (experimental feature)", 39 | Long: `Detect drift in your infrastructure and send Slack notifications`, 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | var notifyService notify.Service 42 | if notifyType != "" { 43 | var notifyErr error 44 | notifyService, notifyErr = notify.New( 45 | notify.WithNotify(notify.NotifyType(notifyType)), 46 | notify.NotifyOn(notify.NotifyFilter(notifyFilter)), 47 | notify.WithSlackConfig(notifySlackConfig), 48 | ) 49 | if notifyErr != nil { 50 | return fmt.Errorf("creating notification service: %w", notifyErr) 51 | } 52 | 53 | } 54 | 55 | // Parse 56 | config, err := parser.Parse(&config.ParserConfig) 57 | if err != nil { 58 | return fmt.Errorf("parsing terraplate: %w", err) 59 | } 60 | // Plan 61 | fmt.Print(terraformStartMessage) 62 | runOpts := []func(r *runner.TerraRunOpts){ 63 | runner.RunBuild(), 64 | runner.RunInit(), 65 | runner.RunPlan(), 66 | runner.RunShowPlan(), 67 | runner.Jobs(planJobs), 68 | } 69 | runOpts = append(runOpts, runner.ExtraArgs(args)) 70 | r := runner.Run(config, runOpts...) 71 | 72 | if notifyService != nil { 73 | repo, repoErr := notify.LookupRepo( 74 | notify.WithRepo(repo), 75 | ) 76 | if repoErr != nil { 77 | return fmt.Errorf("looking up repository details: %w", repoErr) 78 | } 79 | sendErr := notifyService.Send(¬ify.Data{ 80 | Runner: r, 81 | Repo: repo, 82 | ResultsURL: notifyResultsUrl, 83 | }) 84 | if sendErr != nil { 85 | return fmt.Errorf("sending notification: %w", sendErr) 86 | } 87 | } 88 | 89 | fmt.Print(r.Summary(runner.OutputLevelAll)) 90 | return nil 91 | }, 92 | } 93 | 94 | func init() { 95 | RootCmd.AddCommand(driftCmd) 96 | 97 | driftCmd.Flags().IntVarP(&planJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") 98 | driftCmd.Flags().StringVar(¬ifyType, "notify", "", "Notification type (only slack supported)") 99 | driftCmd.Flags().StringVar(¬ifyFilter, "notify-on", "", "When to send a notification (possible values are \"all\" and \"drift\")") 100 | driftCmd.Flags().StringVar(¬ifySlackConfig.Channel, "slack-channel", "", "Slack channel where to send the notification (required if notify=slack)") 101 | driftCmd.Flags().StringVar(¬ifyResultsUrl, "results-url", "", "Provide a custom URL that will be shown in the notification (such as a link to your CI log for easy access)") 102 | driftCmd.Flags().StringVar(&repo.Name, "repo-name", "", "Name of the repository to show in notifications") 103 | driftCmd.Flags().StringVar(&repo.Branch, "repo-branch", "", "Branch of the repository to show in notifications") 104 | } 105 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/verifa/terraplate/parser" 23 | "github.com/verifa/terraplate/runner" 24 | ) 25 | 26 | var ( 27 | initJobs int 28 | initSkipBuild bool 29 | initUpgrade bool 30 | ) 31 | 32 | // initCmd represents the init command 33 | var initCmd = &cobra.Command{ 34 | Use: "init", 35 | Short: "Runs terraform init on all subdirectories", 36 | Long: `Runs terraform init on all subdirectories.`, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | config, err := parser.Parse(&config.ParserConfig) 39 | if err != nil { 40 | return fmt.Errorf("parsing terraplate: %w", err) 41 | } 42 | runOpts := []func(r *runner.TerraRunOpts){ 43 | runner.RunInit(), 44 | runner.Jobs(initJobs), 45 | runner.ExtraArgs(args), 46 | } 47 | 48 | if !initSkipBuild { 49 | runOpts = append(runOpts, runner.RunBuild()) 50 | } 51 | if initUpgrade { 52 | runOpts = append(runOpts, runner.RunInitUpgrade()) 53 | } 54 | 55 | runOpts = append(runOpts, runner.ExtraArgs(args)) 56 | r := runner.Run(config, runOpts...) 57 | fmt.Println(r.Log(runner.OutputLevelAll)) 58 | fmt.Println(r.Summary(runner.OutputLevelAll)) 59 | 60 | return r.Errors() 61 | }, 62 | } 63 | 64 | func init() { 65 | RootCmd.AddCommand(initCmd) 66 | 67 | initCmd.Flags().BoolVar(&initSkipBuild, "skip-build", false, "Skip build process (default: false)") 68 | initCmd.Flags().BoolVarP(&initUpgrade, "upgrade", "u", false, "Perform upgrade when initializing") 69 | initCmd.Flags().IntVarP(&initJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") 70 | } 71 | -------------------------------------------------------------------------------- /cmd/messages.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | ) 6 | 7 | var ( 8 | errorColor = color.New(color.FgRed, color.Bold) 9 | boldText = color.New(color.Bold) 10 | terraformStartMessage = boldText.Sprint("\nTerraforming root modules...\n\n") 11 | devStartMessage = boldText.Sprint("\nStarting dev mode...\n\n") 12 | ) 13 | -------------------------------------------------------------------------------- /cmd/parse.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/verifa/terraplate/parser" 23 | ) 24 | 25 | // parseCmd represents the parse command 26 | var parseCmd = &cobra.Command{ 27 | Use: "parse", 28 | Short: "Parse the terraplate files and print a summary", 29 | Long: `Parse the terraplate files and print a summary. 30 | 31 | This is useful if you want to check the configuration before running 32 | the build command, for example.`, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | config, err := parser.Parse(&config.ParserConfig) 35 | if err != nil { 36 | return fmt.Errorf("parsing terraplate: %w", err) 37 | } 38 | for _, tf := range config.RootModules() { 39 | fmt.Println("Root Module:", tf.Path) 40 | 41 | data, dataErr := tf.BuildData() 42 | if dataErr != nil { 43 | return fmt.Errorf("getting build data for %s: %w", tf.Path, dataErr) 44 | } 45 | fmt.Println("Templates:") 46 | for _, tmpl := range tf.Templates { 47 | condition, condErr := tmpl.Condition(data) 48 | if condErr != nil { 49 | return fmt.Errorf("evaluating condition for template \"%s\" in %s: %w", tmpl.Name, tf.Path, condErr) 50 | } 51 | if condition { 52 | fmt.Printf(" - %s --> %s\n", tmpl.Name, tmpl.Target) 53 | } 54 | } 55 | fmt.Println("Variables:") 56 | for name := range tf.Variables() { 57 | fmt.Println(" -", name) 58 | } 59 | fmt.Println("Locals:") 60 | for name := range tf.Locals() { 61 | fmt.Println(" -", name) 62 | } 63 | fmt.Println("Values:") 64 | for name := range tf.Values() { 65 | fmt.Println(" -", name) 66 | } 67 | fmt.Println("") 68 | } 69 | 70 | return nil 71 | }, 72 | } 73 | 74 | func init() { 75 | RootCmd.AddCommand(parseCmd) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/plan.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "io" 21 | "os" 22 | 23 | tea "github.com/charmbracelet/bubbletea" 24 | "github.com/spf13/cobra" 25 | "github.com/verifa/terraplate/parser" 26 | "github.com/verifa/terraplate/runner" 27 | "github.com/verifa/terraplate/tui" 28 | ) 29 | 30 | var ( 31 | planSkipBuild bool 32 | runInit bool 33 | planJobs int 34 | planDevMode bool 35 | planOutputLevel string 36 | ) 37 | 38 | // planCmd represents the plan command 39 | var planCmd = &cobra.Command{ 40 | Use: "plan", 41 | Short: "Runs terraform plan on all subdirectories", 42 | Long: `Runs terraform plan on all subdirectories.`, 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | 45 | outputLevel, err := runner.OutputLevel(planOutputLevel).Validate() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | config, err := parser.Parse(&config.ParserConfig) 51 | if err != nil { 52 | return fmt.Errorf("parsing terraplate: %w", err) 53 | } 54 | fmt.Print(terraformStartMessage) 55 | runOpts := []func(r *runner.TerraRunOpts){ 56 | runner.RunPlan(), 57 | runner.RunShowPlan(), 58 | runner.Jobs(planJobs), 59 | } 60 | if !planSkipBuild { 61 | runOpts = append(runOpts, runner.RunBuild()) 62 | } 63 | if runInit { 64 | runOpts = append(runOpts, runner.RunInit()) 65 | } 66 | runOpts = append(runOpts, runner.ExtraArgs(args)) 67 | r := runner.Run(config, runOpts...) 68 | 69 | if planDevMode { 70 | // Start dev mode 71 | fmt.Print(devStartMessage) 72 | runOpts := []func(r *runner.TerraRunOpts){ 73 | runner.RunBuild(), 74 | runner.RunPlan(), 75 | runner.RunShowPlan(), 76 | runner.Jobs(planJobs), 77 | // Discard any output from the runner itself. 78 | // This does not discard the Terraform output. 79 | runner.Output(io.Discard), 80 | } 81 | // Override options in runner 82 | r.Opts = runner.NewOpts(runOpts...) 83 | p := tea.NewProgram( 84 | tui.New(r), 85 | tea.WithAltScreen(), 86 | tea.WithMouseCellMotion(), 87 | ) 88 | if err := p.Start(); err != nil { 89 | fmt.Printf("Alas, there's been an error: %v", err) 90 | os.Exit(1) 91 | } 92 | 93 | return nil 94 | } 95 | 96 | fmt.Println(r.Log(outputLevel)) 97 | 98 | fmt.Println(r.Summary(outputLevel)) 99 | 100 | return r.Errors() 101 | }, 102 | } 103 | 104 | func init() { 105 | RootCmd.AddCommand(planCmd) 106 | 107 | planCmd.Flags().BoolVar(&planSkipBuild, "skip-build", false, "Skip build process (default: false)") 108 | planCmd.Flags().BoolVar(&runInit, "init", false, "Run terraform init also") 109 | planCmd.Flags().BoolVar(&planDevMode, "dev", false, "Start dev mode after plan finishes") 110 | planCmd.Flags().IntVarP(&planJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") 111 | planCmd.Flags().StringVar(&planOutputLevel, "output-level", string(runner.OutputLevelAll), "Level of output to show (all or drift)") 112 | } 113 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "os" 21 | 22 | "github.com/spf13/cobra" 23 | "github.com/verifa/terraplate/parser" 24 | ) 25 | 26 | type cmdConfig struct { 27 | ParserConfig parser.Config 28 | } 29 | 30 | var config cmdConfig 31 | 32 | // RootCmd represents the base command when called without any subcommands 33 | var RootCmd = &cobra.Command{ 34 | Use: "terraplate", 35 | Short: "DRY Terraform using Go Templates", 36 | Long: `DRY Terraform using Go Templates. 37 | 38 | Terraplate keeps your Terraform DRY. 39 | Create templates that get built using Go Templates to avoid repeating common 40 | Terraform configurations like providers and backend.`, 41 | SilenceUsage: true, 42 | SilenceErrors: true, 43 | DisableAutoGenTag: true, 44 | } 45 | 46 | // Execute adds all child commands to the root command and sets flags appropriately. 47 | // This is called by main.main(). It only needs to happen once to the RootCmd. 48 | func Execute() { 49 | err := RootCmd.Execute() 50 | if err != nil { 51 | // Pretty print the error before finishing 52 | fmt.Printf("\n%s %s\n", errorColor.Sprint("Error:"), err.Error()) 53 | os.Exit(1) 54 | } 55 | } 56 | 57 | func init() { 58 | RootCmd.PersistentFlags().StringVarP(&config.ParserConfig.Chdir, "chdir", "C", ".", "Switch to a different working directory before executing the given subcommand.") 59 | } 60 | -------------------------------------------------------------------------------- /cmd/show.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "io" 21 | 22 | "github.com/spf13/cobra" 23 | "github.com/verifa/terraplate/parser" 24 | "github.com/verifa/terraplate/runner" 25 | ) 26 | 27 | var ( 28 | showCmdJobs int 29 | showCmdOutputLevel string 30 | showCmdShowProgress bool 31 | ) 32 | 33 | // showCmd represents the show command 34 | var showCmd = &cobra.Command{ 35 | Use: "show", 36 | Short: "Runs terraform show on all subdirectories", 37 | Long: `Runs terraform show on all subdirectories.`, 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | 40 | outputLevel, err := runner.OutputLevel(showCmdOutputLevel).Validate() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | config, err := parser.Parse(&config.ParserConfig) 46 | if err != nil { 47 | return fmt.Errorf("parsing terraplate: %w", err) 48 | } 49 | if showCmdShowProgress { 50 | fmt.Print(terraformStartMessage) 51 | } 52 | runOpts := []func(r *runner.TerraRunOpts){ 53 | runner.RunShow(), 54 | runner.RunShowPlan(), 55 | runner.Jobs(showCmdJobs), 56 | } 57 | if !showCmdShowProgress { 58 | runOpts = append(runOpts, runner.Output(io.Discard)) 59 | } 60 | 61 | runOpts = append(runOpts, runner.ExtraArgs(args)) 62 | r := runner.Run(config, runOpts...) 63 | 64 | fmt.Println(r.Log(outputLevel)) 65 | 66 | fmt.Println(r.Summary(outputLevel)) 67 | 68 | return r.Errors() 69 | }, 70 | } 71 | 72 | func init() { 73 | RootCmd.AddCommand(showCmd) 74 | 75 | showCmd.Flags().IntVarP(&showCmdJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") 76 | showCmd.Flags().StringVar(&showCmdOutputLevel, "output-level", string(runner.OutputLevelAll), "Level of output to show (all or drift)") 77 | showCmd.Flags().BoolVar(&showCmdShowProgress, "show-progress", true, "Whether to show Terraform run progress") 78 | } 79 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | var ( 25 | version = "dev" 26 | commit = "dev" 27 | date = "dev" 28 | ) 29 | 30 | // versionCmd represents the version command 31 | var versionCmd = &cobra.Command{ 32 | Use: "version", 33 | Short: "Show the Terraplate version", 34 | Run: func(cmd *cobra.Command, args []string) { 35 | fmt.Println("Terraplate version", version) 36 | fmt.Println("Commit:", commit) 37 | fmt.Println("Date:", date) 38 | }, 39 | } 40 | 41 | func init() { 42 | RootCmd.AddCommand(versionCmd) 43 | } 44 | -------------------------------------------------------------------------------- /docs/.pages: -------------------------------------------------------------------------------- 1 | # Page tree 2 | nav: 3 | - Home: 4 | - index.md 5 | - quick-start.md 6 | - installation.md 7 | - examples.md 8 | - project-status.md 9 | - Reference: reference 10 | - Tutorials: tutorials 11 | - Commands: commands 12 | - FAQ: faq 13 | - ... 14 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | terraplate.verifa.io -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/images/terraplate-file-structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/docs/assets/images/terraplate-file-structure.png -------------------------------------------------------------------------------- /docs/assets/images/tp-dev-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/docs/assets/images/tp-dev-mode.png -------------------------------------------------------------------------------- /docs/assets/images/tutorials/drift-slack-notification/slack-drift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/docs/assets/images/tutorials/drift-slack-notification/slack-drift.png -------------------------------------------------------------------------------- /docs/assets/images/tutorials/drift-slack-notification/slack-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/docs/assets/images/tutorials/drift-slack-notification/slack-error.png -------------------------------------------------------------------------------- /docs/assets/images/tutorials/drift-slack-notification/slack-no-drift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/docs/assets/images/tutorials/drift-slack-notification/slack-no-drift.png -------------------------------------------------------------------------------- /docs/assets/logo/terraplate-short-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /docs/assets/logo/terraplate-verifa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 13 | 19 | 20 | 21 | 26 | 28 | 34 | 40 | 45 | 50 | 56 | 57 | 63 | 68 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /docs/assets/logo/terraplate-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 14 | 16 | 19 | 22 | 26 | 27 | 31 | 35 | 38 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/commands/.pages: -------------------------------------------------------------------------------- 1 | arrange: 2 | - terraplate.md 3 | - ... 4 | -------------------------------------------------------------------------------- /docs/commands/terraplate.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate" 4 | --- 5 | ## terraplate 6 | 7 | DRY Terraform using Go Templates 8 | 9 | ### Synopsis 10 | 11 | DRY Terraform using Go Templates. 12 | 13 | Terraplate keeps your Terraform DRY. 14 | Create templates that get built using Go Templates to avoid repeating common 15 | Terraform configurations like providers and backend. 16 | 17 | ### Options 18 | 19 | ``` 20 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 21 | -h, --help help for terraplate 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [terraplate apply](terraplate_apply.md) - Runs terraform apply on all subdirectories 27 | * [terraplate build](terraplate_build.md) - Build Terraform files based your Terrafiles 28 | * [terraplate dev](terraplate_dev.md) - Enters dev mode which launches a Terminal UI for Terraplate 29 | * [terraplate drift](terraplate_drift.md) - Detect drift in your infrastructure (experimental feature) 30 | * [terraplate init](terraplate_init.md) - Runs terraform init on all subdirectories 31 | * [terraplate parse](terraplate_parse.md) - Parse the terraplate files and print a summary 32 | * [terraplate plan](terraplate_plan.md) - Runs terraform plan on all subdirectories 33 | * [terraplate show](terraplate_show.md) - Runs terraform show on all subdirectories 34 | * [terraplate version](terraplate_version.md) - Show the Terraplate version 35 | 36 | -------------------------------------------------------------------------------- /docs/commands/terraplate_apply.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate apply" 4 | --- 5 | ## terraplate apply 6 | 7 | Runs terraform apply on all subdirectories 8 | 9 | ### Synopsis 10 | 11 | Runs terraform apply on all subdirectories. 12 | 13 | ``` 14 | terraplate apply [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for apply 21 | -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) 22 | ``` 23 | 24 | ### Options inherited from parent commands 25 | 26 | ``` 27 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 33 | 34 | -------------------------------------------------------------------------------- /docs/commands/terraplate_build.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate build" 4 | --- 5 | ## terraplate build 6 | 7 | Build Terraform files based your Terrafiles 8 | 9 | ### Synopsis 10 | 11 | Build (or generate) the Terraform files. 12 | 13 | For each Terrafile that is detected, build the Terraform files using the 14 | templates and configurations detected. 15 | 16 | ``` 17 | terraplate build [flags] 18 | ``` 19 | 20 | ### Options 21 | 22 | ``` 23 | -h, --help help for build 24 | --validate Validate (requires init) each root module after build 25 | ``` 26 | 27 | ### Options inherited from parent commands 28 | 29 | ``` 30 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 36 | 37 | -------------------------------------------------------------------------------- /docs/commands/terraplate_dev.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate dev" 4 | --- 5 | ## terraplate dev 6 | 7 | Enters dev mode which launches a Terminal UI for Terraplate 8 | 9 | ### Synopsis 10 | 11 | Enters dev mode which launches a Terminal UI for building and running Terraplate root modules. 12 | 13 | ``` 14 | terraplate dev [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for dev 21 | -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) 22 | ``` 23 | 24 | ### Options inherited from parent commands 25 | 26 | ``` 27 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 33 | 34 | -------------------------------------------------------------------------------- /docs/commands/terraplate_drift.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate drift" 4 | --- 5 | ## terraplate drift 6 | 7 | Detect drift in your infrastructure (experimental feature) 8 | 9 | ### Synopsis 10 | 11 | Detect drift in your infrastructure and send Slack notifications 12 | 13 | ``` 14 | terraplate drift [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for drift 21 | -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) 22 | --notify string Notification type (only slack supported) 23 | --notify-on string When to send a notification (possible values are "all" and "drift") 24 | --repo-branch string Branch of the repository to show in notifications 25 | --repo-name string Name of the repository to show in notifications 26 | --results-url string Provide a custom URL that will be shown in the notification (such as a link to your CI log for easy access) 27 | --slack-channel string Slack channel where to send the notification (required if notify=slack) 28 | ``` 29 | 30 | ### Options inherited from parent commands 31 | 32 | ``` 33 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 34 | ``` 35 | 36 | ### SEE ALSO 37 | 38 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 39 | 40 | -------------------------------------------------------------------------------- /docs/commands/terraplate_init.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate init" 4 | --- 5 | ## terraplate init 6 | 7 | Runs terraform init on all subdirectories 8 | 9 | ### Synopsis 10 | 11 | Runs terraform init on all subdirectories. 12 | 13 | ``` 14 | terraplate init [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for init 21 | -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) 22 | --skip-build Skip build process (default: false) 23 | -u, --upgrade Perform upgrade when initializing 24 | ``` 25 | 26 | ### Options inherited from parent commands 27 | 28 | ``` 29 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 30 | ``` 31 | 32 | ### SEE ALSO 33 | 34 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 35 | 36 | -------------------------------------------------------------------------------- /docs/commands/terraplate_parse.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate parse" 4 | --- 5 | ## terraplate parse 6 | 7 | Parse the terraplate files and print a summary 8 | 9 | ### Synopsis 10 | 11 | Parse the terraplate files and print a summary. 12 | 13 | This is useful if you want to check the configuration before running 14 | the build command, for example. 15 | 16 | ``` 17 | terraplate parse [flags] 18 | ``` 19 | 20 | ### Options 21 | 22 | ``` 23 | -h, --help help for parse 24 | ``` 25 | 26 | ### Options inherited from parent commands 27 | 28 | ``` 29 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 30 | ``` 31 | 32 | ### SEE ALSO 33 | 34 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 35 | 36 | -------------------------------------------------------------------------------- /docs/commands/terraplate_plan.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate plan" 4 | --- 5 | ## terraplate plan 6 | 7 | Runs terraform plan on all subdirectories 8 | 9 | ### Synopsis 10 | 11 | Runs terraform plan on all subdirectories. 12 | 13 | ``` 14 | terraplate plan [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | --dev Start dev mode after plan finishes 21 | -h, --help help for plan 22 | --init Run terraform init also 23 | -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) 24 | --output-level string Level of output to show (all or drift) (default "all") 25 | --skip-build Skip build process (default: false) 26 | ``` 27 | 28 | ### Options inherited from parent commands 29 | 30 | ``` 31 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 37 | 38 | -------------------------------------------------------------------------------- /docs/commands/terraplate_show.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate show" 4 | --- 5 | ## terraplate show 6 | 7 | Runs terraform show on all subdirectories 8 | 9 | ### Synopsis 10 | 11 | Runs terraform show on all subdirectories. 12 | 13 | ``` 14 | terraplate show [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for show 21 | -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) 22 | --output-level string Level of output to show (all or drift) (default "all") 23 | --show-progress Whether to show Terraform run progress (default true) 24 | ``` 25 | 26 | ### Options inherited from parent commands 27 | 28 | ``` 29 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 30 | ``` 31 | 32 | ### SEE ALSO 33 | 34 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 35 | 36 | -------------------------------------------------------------------------------- /docs/commands/terraplate_version.md: -------------------------------------------------------------------------------- 1 | --- 2 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 3 | title: "terraplate version" 4 | --- 5 | ## terraplate version 6 | 7 | Show the Terraplate version 8 | 9 | ``` 10 | terraplate version [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for version 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [terraplate](terraplate.md) - DRY Terraform using Go Templates 28 | 29 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Examples" 3 | description: "Terraplate examples" 4 | --- 5 | 6 | Check out the Terraplate [examples](https://github.com/verifa/terraplate/tree/main/examples) in our GitHub repository. 7 | -------------------------------------------------------------------------------- /docs/faq/terraplate-vs-terragrunt.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Terraplate vs Terragrunt" 3 | description: "Terraplate and Terragrunt solve similar problems, in a similar way. So why create Terraplate?" 4 | --- 5 | 6 | Terragrunt is an amazing tool, that shaped a lot of how Terraform is used today (including Terraplate). 7 | However, there are things about Terragrunt that motivated us to write Terraplate, and those have been summarised below. 8 | This is in no way to say that Terraplate is *better* than Terragrunt, but we do feel it is simpler. 9 | 10 | ## Native Terraform Syntax 11 | 12 | Terragrunt does not produce native Terraform code that can be read or version controlled. 13 | Of course in the end, Terragrunt generates native Terraform code which is invoked using the Terraform CLI, but this happens at runtime and is managed by Terragrunt. 14 | 15 | This means: 16 | 17 | 1. You are dependent on Terragrunt to run Terraform 18 | - This rules out tools like [Terraform Cloud](https://cloud.hashicorp.com/products/terraform) 19 | 2. There are subtle differences in syntax between using Terraform features, like modules, which as a concept existed in Terragrunt before they existed in Terraform 20 | 21 | ## Calling none or multiple modules 22 | 23 | Terragrunt is designed to call exactly one Terraform module from each Root Module. 24 | Sometimes you don't want to call a Terraform module, or might want to call multiple Terraform modules from the same Root Module. 25 | 26 | With Terraplate, you can use the templating engine to create things like the backend and providers, and then write a plain `.tf` file yourself which can do whatever you want it to. 27 | 28 | ## Keep it DRY, only 29 | 30 | Terraplate can be used for *just* it's templating engine. 31 | That way, you can keep your Terraform code DRY whilst not changing the way you invoke Terraform (be it via CI, Terraform Cloud, SpaceLift, env0, etc). 32 | 33 | ## Less boilerplate 34 | 35 | Terraplate has inheritance built in without being explicit (e.g. functions like `find_in_parent_folders()` don't need to be used). 36 | Whilst this is very minor, it does reduce the amount of boilerplate needed in your `terraplate.hcl` configurations. 37 | In fact, some `terraplate.hcl` files can be completely empty, because they inherit everything from parent `terraplate.hcl` files. 38 | 39 | ## Extra comments 40 | 41 | There's a lot of things you can do with Terragrunt that you cannot do with Terraplate. 42 | Like mentioned, we are Terragrunt fans and have been trying to find a happy place using *just* Terraform, and that's why Terraplate was created. 43 | If you start with Terraplate and find it's not for you; that's ok, there's no lock-in as all the files are just vanilla Terraform. 44 | 45 | If you are a Terragrunt user and find useful things missing, please raise an issue or discussion :) 46 | -------------------------------------------------------------------------------- /docs/faq/terraplate-vs-tf-workspaces.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Terraplate vs Terraform Workspaces" 3 | description: "Terraplate and Terraform workspaces help keep Terraform DRY. So how do they compare?" 4 | --- 5 | 6 | ## Terraform Workspaces 7 | 8 | [Terraform Workspaces](https://www.terraform.io/language/state/workspaces) are a feature of Terraform that enable you to use different backends (state storage) for a single root module (i.e. directory). 9 | This means you can have exactly the same Terraform code, and substitue the variables to manage different environments. 10 | Thus, workspaces help to keep Terraform DRY but only for a *single Root Module* with Terraform variable substitution. 11 | 12 | Terraplate, on the other hand, allows you to template any Terraform code (such as backend, providers, resources, module invocations) for any Root Module that has a Terrafile. 13 | 14 | Workspaces and Terraplate solve similar issues, but should be not be considered directly as alternatives. 15 | In fact, you can use Workspaces and Terraplate together. 16 | 17 | ### When to use workspaces 18 | 19 | Terraform's own [documentation](https://www.terraform.io/language/state/workspaces#when-to-use-multiple-workspaces) do not recommend workspaces in certain cases: 20 | 21 | > *Workspaces alone are not a suitable tool for system decomposition, because each subsystem should have its own separate configuration and backend, and will thus have its own distinct set of workspaces.* 22 | -------------------------------------------------------------------------------- /docs/gen.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/cobra/doc" 10 | "github.com/verifa/terraplate/cmd" 11 | ) 12 | 13 | func main() { 14 | genCLIDocs() 15 | } 16 | 17 | func genCLIDocs() { 18 | err := doc.GenMarkdownTreeCustom( 19 | cmd.RootCmd, 20 | "./docs/commands", 21 | func(s string) string { 22 | filename := filepath.Base(s) 23 | name := filename[:len(filename)-len(filepath.Ext(filename))] 24 | name = strings.Join(strings.Split(name, "_"), " ") 25 | return fmt.Sprintf(`--- 26 | # # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) 27 | title: "%s" 28 | --- 29 | `, name) 30 | }, 31 | func(s string) string { 32 | return s 33 | }, 34 | ) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | --- 4 | 5 | Terraplate logo 6 | 7 | Terraplate is a thin wrapper around [Terraform](https://terraform.io) to keep things DRY and improve the developer experience. 8 | 9 | ## How it works 10 | 11 | Terraplate traverses up and down from the working directory detecting Terraplate files (AKA "Terrafiles"), treating the Terrafiles without child Terrafiles as [Root Modules](https://www.terraform.io/language/modules#the-root-module) (i.e. if a Terrafile does not have any children, it's considered a Root Module where Terraform should be run). 12 | 13 | Terraplate builds Terraform files based on your provided templates (using Go Templates). 14 | Define your Terraform snippets once, and reuse them with Go Templates to substitute the values based on the different root modules. 15 | 16 | Terraplate example file structure 17 | 18 | The built files are completely normal Terraform files that should be **version controlled** and can be applied either via the `terraform` CLI or using the `terraplate` CLI. 19 | This way you can focus on writing your Terraform code that creates resources, and let Terraplate handle the boilerplate (like backend, providers, configuration, etc) based on your provided templates. 20 | 21 | The goal of Terraplate is to not do any magic: just plain (but DRY) Terraform, which means you can bring your own tools for static analysis, security, policies, testing and deployment. 22 | 23 | The `terraplate` CLI allows you to run Terraform across all your Root Modules and provide a summary of plans. 24 | 25 | 26 | 27 | 28 | ## Motivation 29 | 30 | As you scale your Terraform usage you will start to split your resources out across multiple Terraform [Root Modules](https://www.terraform.io/language/modules#the-root-module). 31 | Each Root Module must define it's own backend (state storage), providers and versions, and this can lead to a lot of copy+paste. Not very DRY. 32 | 33 | ### Keeping things DRY 34 | 35 | There are existing techniques to keep things DRY, two notable mentions: 36 | 37 | 1. [Terraform Workspaces](https://www.terraform.io/cli/workspaces) solve the issue when you have multiple environments (e.g. prod & dev) for the same infrastructure, not multiple completely unrelated Root Modules. Nonetheless, it helps reduce the amount of copied code. Check the [FAQ on the subject](./faq/terraplate-vs-tf-workspaces.md). 38 | 2. [Terragrunt](https://terragrunt.gruntwork.io/): Terragrunt inspired Terraplate and therefore it is no surprise that Terraplate has a similar feel. However, there are differences that we feel warranted the development of another tool. Check the [FAQ on the subject](./faq/terraplate-vs-terragrunt.md). 39 | 40 | ### Multiple Root Modules 41 | 42 | There are existing approaches to running Terraform (e.g. init, plan, apply) over multiple Root Modules (directories): 43 | 44 | 1. [Terragrunt](https://terragrunt.gruntwork.io/): This is one of Terragrunt's core features, but only works if you use Terragrunt 45 | 2. [Terraform Cloud](https://cloud.hashicorp.com/products/terraform): Terraform cloud allows you to create Workspaces for each Root Module 46 | 3. [SpaceLift](https://spacelift.io/): SpaceLift is similar to Terraform Cloud but calls it "Stacks" instead of Workspaces. 47 | 4. Custom: write a bash script or something and run it in your CI :) 48 | 49 | Terraplate's templating is compatible with all the approaches above (in fact Terraplate can template your Terragrunt files! But let's not go there...). 50 | That means you can use Terraplate for **just templating and keeping things DRY** but it also comes with a thin wrapper for invoking Terraform (similar to Terragrunt). 51 | 52 | ### Drift detection 53 | 54 | Terraplate can be run on a regular basis and detect drift in your infrastructure and create notifications. 55 | Currenly only Slack will be supported, but we welcome ideas/suggestions for more! 56 | 57 | ## Who is it for 58 | 59 | ### Terraform users with multiple [Root Modules](https://www.terraform.io/language/modules#the-root-module) 60 | 61 | Once you start to scale your Terraform usage you will not want to put all of your code into a single root module (i.e. a single state). 62 | 63 | The two main benefits Terraplate brings is: 64 | 65 | 1. Keeping your code DRY and more maintainable 66 | 2. Improve developer productivity by spending less time writing boilerplate and running Terraform across all your Root Modules 67 | 68 | #### Dev mode 69 | 70 | Terraplate has a "dev" mode which launches a Terminal UI, for browsing Root modules and running commands: 71 | 72 | ![dev-mode](../assets/images/tp-dev-mode.png) 73 | 74 | ### Terraform users who want to make [Workspaces](https://www.terraform.io/cli/workspaces) more DRY or avoid them 75 | 76 | If you don't find workspaces completely solves the issue of DRY infra, or they are not right for you, Terraplate is worth considering. 77 | Terraplate is not a replacement, but something that can solve the same problem and be used together with workspaces. 78 | Check the [FAQ on the subject]({{< relref "../faq/terraplate-vs-tf-workspaces" >}}). 79 | 80 | ### Overcoming limitations of Terraform's dynamic behavior 81 | 82 | An example of a limitation is the ability to do `for_each` for providers (or even dynamically reference providers to pass to modules using a `for_each`). 83 | With Terraplate, you can build the `.tf` Terraform file that creates the providers and invokes the modules and overcome this. 84 | It's not the cleanest, but we've found it much friendlier than the numerous workarounds we have to do to achieve the same thing with vanilla Terraform. 85 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Installation" 3 | description: "How to install Terraplate." 4 | --- 5 | 6 | Terraplate is written in Go and uses the awesome [GoReleaser](https://goreleaser.com/) project to release and publish. 7 | 8 | ### Docker 9 | 10 | ```bash 11 | docker pull verifa/terraplate 12 | ``` 13 | 14 | ### Brew 15 | 16 | ```bash 17 | brew install verifa/tap/terraplate 18 | ``` 19 | 20 | ### Standalone 21 | 22 | Check the GitHub Releases: 23 | -------------------------------------------------------------------------------- /docs/project-status.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Project Status" 3 | description: "Terraplate project status" 4 | --- 5 | 6 | Terraplate is **stable** and being used in production. There should be no breaking changes unless there is a new major version. 7 | 8 | Terraplate does not have a cache, cleanup functionality or overwrite protection. 9 | It's quite dumb, by design, so please be careful that you do not overwrite existing files. 10 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Quick Start" 3 | description: "One page summary of how to get started with Terraplate." 4 | --- 5 | 6 | Let's get you using Terraplate ASAP! 7 | 8 | ## Installation 9 | 10 | See [installation instructions](./installation.md) 11 | 12 | ## Example 13 | 14 | ### Clone Terraplate 15 | 16 | The Terraplate repository comes with some examples. Let's start with the simple one. 17 | 18 | ```console 19 | 20 | git clone https://github.com/verifa/terraplate.git 21 | 22 | cd terraplate/examples/simple 23 | ``` 24 | 25 | ### Run Terraplate 26 | 27 | ```console 28 | # Parse the Terrafiles and print some details 29 | terraplate parse 30 | 31 | # Build the templates 32 | terraplate build 33 | 34 | # Plan the root modules 35 | terraplate plan 36 | 37 | # Apply the root modules 38 | terraplate apply 39 | ``` 40 | 41 | ## Tutorials 42 | 43 | Check the [tutorials](./tutorials/multiple-root-modules.md) for learning about how to setup a project using Terraplate 44 | 45 | ## Reference 46 | 47 | The [complete terrafile](./reference/complete.md) reference tells you everything you can put into a Terrafile. 48 | -------------------------------------------------------------------------------- /docs/reference/.pages: -------------------------------------------------------------------------------- 1 | arrange: 2 | - terrafile.md 3 | # - templates.md 4 | - ... 5 | -------------------------------------------------------------------------------- /docs/reference/complete.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Complete Terrafile" 3 | description: "Complete Terrafile configuration" 4 | --- 5 | 6 | ```terraform title="examples/complete/terraplate.hcl" 7 | --8<-- "examples/complete/terraplate.hcl" 8 | ``` 9 | 10 | -------------------------------------------------------------------------------- /docs/reference/terrafile.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Terrafile" 3 | description: "Terrafile reference" 4 | --- 5 | 6 | Here are the different configurations that are supported in a Terrafile. 7 | 8 | ## Locals 9 | 10 | `locals` block defines a map of Terraform locals that will be written to the `terraplate.tf` file. 11 | 12 | Use the `locals` block for controlling values in your root modules, e.g. `environment`, `region`, `project`. 13 | 14 | Use `locals` when you want to reference these values in your Terraform code. 15 | Prefer locals over variables unless you want to override something at runtime; in that case `variables` are your friend. 16 | 17 | Example: 18 | 19 | ```terraform title="terraplate.hcl" 20 | locals { 21 | environment = "dev" 22 | } 23 | ``` 24 | 25 | Output: 26 | 27 | ```terraform title="terraplate.tf" 28 | locals { 29 | environment = "dev" 30 | } 31 | ``` 32 | 33 | Use this in your Terraform files as a normal Terraform variable, e.g. `${local.environment}` 34 | 35 | ## Variables 36 | 37 | `variables` block defines a map of Terraform variables that will be written to the `terraplate.tf` file with default values. 38 | 39 | Prefer `locals` over variables if you will not be overriding inputs at runtime. 40 | 41 | Example: 42 | 43 | ```terraform title="terraplate.hcl" 44 | variables { 45 | environment = "dev" 46 | } 47 | ``` 48 | 49 | Output: 50 | 51 | ```terraform title="terraplate.tf" 52 | variable "environment" { 53 | default = "dev" 54 | } 55 | ``` 56 | 57 | Use this in your Terraform files as a normal Terraform variable, e.g. `${var.environment}` 58 | 59 | ## Values 60 | 61 | `values` block defines a map of values that are passed to the Go template executor when running the Terraplate build process. 62 | 63 | Use this instead of `locals` or `variables` if you do not want to expose values as Terraform variables but only want to use them during the build process. 64 | A prime example of this is configuring the Terraform backend because variables cannot be used for this. 65 | 66 | Example: 67 | 68 | ```terraform title="terraplate.hcl" 69 | values { 70 | some_value = "hello!" 71 | } 72 | ``` 73 | 74 | ```terraform title="templates/some_value.tp.tf.hcl" 75 | locals { 76 | some_value = "{{ .Values.some_value }}" 77 | } 78 | ``` 79 | 80 | Output: 81 | 82 | ```terraform title="some_value.tp.tf" 83 | locals { 84 | some_value = "hello!" 85 | } 86 | ``` 87 | 88 | ## Templates 89 | 90 | `template` block defines a template that will be built to all child root modules (as Terrafiles inherit from their parents). 91 | 92 | Templates can also define non-Terraform files in case you want to just do some general-purpose templating, such as creating Makefiles or something spicy. 93 | But we're just gonna do plain ol' DRY Terraform. 94 | 95 | Example: 96 | 97 | ```terraform title="backend.tmpl" 98 | terraform { 99 | backend "s3" { 100 | bucket = "bucket-name" 101 | key = "{{ .RelativeDir }}/terraform.tfstate" 102 | region = "{{ .Locals.aws_region }}" 103 | encrypt = true 104 | } 105 | } 106 | ``` 107 | 108 | ```terraform title="terraplate.hcl" 109 | # Define a template to be built, reading the template we have defined. 110 | # All child terrafiles will inherit and build this template. 111 | template "backend" { 112 | # read_template is a custom function that parses up the directory tree, 113 | # looking for a matching template file 114 | contents = read_template("backend.tmpl") 115 | # target is optional, and defaults to the template name with a "tp.tf" suffix 116 | # (e.g. "backend.tp.tf" for this template) 117 | target = "backend.tp.tf" 118 | } 119 | 120 | # Templates can also embed the contents directly 121 | template "embedded" { 122 | contents = <<-EOL 123 | # Template this 124 | EOL 125 | } 126 | 127 | # Templates can also have conditional logic when to build them, which makes them 128 | # very powerful 129 | template "provider_aws_dev" { 130 | contents = <<-EOL 131 | provider "aws" { 132 | alias = "dev" 133 | region = "eu-west-1" 134 | access_key = "my-access-key" 135 | secret_key = "my-secret-key" 136 | } 137 | EOL 138 | # Specify a condition, which if it evaluates to true, will build the template 139 | # in that root module 140 | condition = "{{ eq .Locals.environment \"dev\" }}" 141 | } 142 | ``` 143 | 144 | ## Required Providers 145 | 146 | `required_providers` defines the required providers for a Terraform root module. 147 | It is built into a `terraform {}` block inside a `terraplate.tf` file. 148 | 149 | Example: 150 | 151 | ```terraform title="terraplate.hcl" 152 | terraform { 153 | required_providers { 154 | local = { 155 | source = "hashicorp/local" 156 | version = "2.1.0" 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | Output: 163 | 164 | ```terraform title="terraplate.tf" 165 | terraform { 166 | # ... 167 | required_providers { 168 | local = { 169 | source = "hashicorp/local" 170 | version = "2.1.0" 171 | } 172 | } 173 | } 174 | ``` 175 | 176 | Terraform Docs: 177 | 178 | ## Required Version 179 | 180 | `required_version` accepts a string. It is built into a `terraform {}` block inside a `terraplate.tf` file. 181 | 182 | Example: 183 | 184 | ```terraform title="terraplate.hcl" 185 | terraform { 186 | required_version = ">= 1.1.0" 187 | } 188 | ``` 189 | 190 | Output: 191 | 192 | ```terraform title="terraplate.tf" 193 | terraform { 194 | required_version = ">= 1.1.0" 195 | # ... 196 | } 197 | ``` 198 | 199 | Terraform Docs: 200 | -------------------------------------------------------------------------------- /docs/stylesheets/theme.css: -------------------------------------------------------------------------------- 1 | :root>* { 2 | --md-primary-fg-color: #ad9ce3; 3 | --md-primary-fg-color--light: #c0b7df; 4 | --md-primary-fg-color--dark: #937cdd; 5 | } -------------------------------------------------------------------------------- /docs/tutorials/.pages: -------------------------------------------------------------------------------- 1 | arrange: 2 | 3 | - multiple-root-modules.md 4 | - dev-mode.md 5 | - drift-slack-notifications.md 6 | -------------------------------------------------------------------------------- /docs/tutorials/dev-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Dev mode (TUI)" 3 | description: "Dev mode launches Terraplate's Terminal UI" 4 | --- 5 | 6 | This tutorial gives a *very* quick overview of Terraplate's `dev` mode. 7 | 8 | ## Dev mode 9 | 10 | Terraplate's `dev` mode launches a Terminal UI for working with multiple root modules and executing Terraform via Terraplate. 11 | 12 | ![dev-mode](../assets/images/tp-dev-mode.png) 13 | 14 | ## Entering dev mode 15 | 16 | You can either run `terraplate dev` or `terraplate plan --dev` to start dev mode. 17 | 18 | With the `dev` subcommand the TUI starts up immediately. 19 | With the `plan --dev` subcommand, the plan finishes before starting the TUI. 20 | 21 | ## Command mode 22 | 23 | Once in dev mode, you can press `:` to enter command mode, and you can do the following: 24 | 25 | - `b` for build 26 | - `i` for init 27 | - `u` for init -upgrade 28 | - `p` for plan 29 | - `a` for apply 30 | - `A` for all (run over all visible root modules) 31 | 32 | Finally press enter to run the commands over the root module(s). 33 | 34 | For example the following keypresses will build, init (with upgrade) and plan all visible root modules: `:bupA`. 35 | 36 | ## Help 37 | 38 | Check the help at the bottom of the TUI. You can press `?` to get the extended help. 39 | -------------------------------------------------------------------------------- /docs/tutorials/drift-slack-notifications.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Drift detection with Slack notifications" 3 | description: "" 4 | --- 5 | 6 | !!! info "Drift detection is currently an experimental feature" 7 | 8 | Terraplate uses the `terraform show` command to output a plan as JSON, which it then parses to detect drift. 9 | The `terraplate drift` commands allows you to run Terraform over all your Root Modules that have a `terraplate.hcl` file ("Terrafile"), and send notifications based on the result. 10 | 11 | Terraplate only provides the capability to do one-off drift detections, so it is **up to you to make this run at a schedule** (e.g. via CI, or Kubernetes CronJob). 12 | 13 | ```bash title="Slack notification example" 14 | # Set the Slack API token which is used to authenticate with 15 | export SLACK_API_TOKEN= 16 | # Detect drift and send a notification if there was drift (or an error) 17 | terraplate drift --notify=slack --slack-channel= --notify-on drift 18 | ``` 19 | 20 | The `--notify` flag says which notification service to use, the `--slack-channel` says which Slack channel to post the notification to, and the `--notify-on` flag accepts either `all` (always send a notificaiton) or `drift` (only send a notification if there's drift detected, or an error occurred). 21 | 22 | ## Configuration 23 | 24 | ### Slack API Token 25 | 26 | Getting a Slack API token requires you to create an application. 27 | 28 | For example, the ArgoCD notes on this are very clear so let's refer to those: 29 | 30 | Set the token via the `SLACK_API_TOKEN` environment variable. 31 | 32 | ### Repository 33 | 34 | The Slack notification requires details on your repository to send to Slack. 35 | There is a simple bit of logic in Terraplate to try and detect the repository name using the `origin` remote, and the repository branch using `HEAD`. 36 | 37 | If this fails, or you want to give something custom, use the `--repo-name` and `--repo-branch` flags, or set the `TP_REPO_NAME` or `TP_REPO_BRANCH` environment variables. 38 | 39 | You can also specify a URL to show under the repository information with the `--results-url` flag, such as a link to your CI logs for easy access. 40 | 41 | ## Example 42 | 43 | ### No drift detected 44 | 45 | !!! info "This is only shown when the `--notify-on=all`" 46 | 47 | ![slack-no-drift](../assets/images/tutorials/drift-slack-notification/slack-no-drift.png) 48 | 49 | ### Drift detected 50 | 51 | ![slack-drift](../assets/images/tutorials/drift-slack-notification/slack-drift.png) 52 | 53 | ### Error occurred 54 | 55 | ![slack-error](../assets/images/tutorials/drift-slack-notification/slack-error.png) 56 | -------------------------------------------------------------------------------- /docs/tutorials/multiple-root-modules.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Multiple root modules" 3 | description: "" 4 | --- 5 | 6 | This tutorial is a light introduction to Terraplate where we take a single Terraform Root Module and split it into multiple root modules, whilst keeping things DRY. 7 | 8 | ## Example Root Module 9 | 10 | Let's setup a basic Terraform root module where we use the `local` provider so you don't need to worry about cloud providers. 11 | 12 | ```console 13 | # Check out the terraplate codebase containing the tutorials 14 | git clone https://github.com/verifa/terraplate.git 15 | 16 | # Go to the tutorial 17 | cd terraplate/tutorials/multiple-root-modules 18 | ``` 19 | 20 | ### Resources (and provider) 21 | 22 | In there we should have a Terraform file with something like the following, which will create two files: one for dev and one for prod. 23 | Ignore that this is stupidly simple and imagine instead you are creating VPCs, VMs, Kubernetes clusters, ... whatever you normally do! 24 | 25 | ```terraform title="main.tf" 26 | --8<-- "tutorials/multiple-root-modules/main.tf" 27 | ``` 28 | 29 | ### Backend 30 | 31 | The `backend.tf` file defines where the Terraform state should be stored. 32 | This config replicates the default backend which is to store the state in a local file called `terraform.tfstate`. 33 | 34 | ```terraform title="backend.tf" 35 | --8<-- "tutorials/multiple-root-modules/backend.tf" 36 | ``` 37 | 38 | ### Versions 39 | 40 | The `versions.tf` file contains the required providers and the Terraform CLI version. 41 | 42 | ```terraform title="versions.tf" 43 | --8<-- "tutorials/multiple-root-modules/versions.tf" 44 | ``` 45 | 46 | ### Apply the configuration 47 | 48 | Now we will apply the configuration using basic Terraform 49 | 50 | ```bash 51 | # Initialize the root module 52 | terraform init 53 | 54 | # Plan the root module 55 | terraform plan -out tfplan 56 | 57 | # Apply based on the plan output 58 | terraform apply tfplan 59 | 60 | # Check output 61 | cat prod.txt 62 | cat dev.txt 63 | ``` 64 | 65 | Great! This should've worked. And let's imagine that it took a long time to plan, because of all your resources being inside a single Root Module and therefore a single state. 66 | 67 | ## Using Terraplate 68 | 69 | Let's refactor this code and split the two `local_file` resources up into their own Root Modules and use Terraplate to keep things DRY. 70 | Take a look in the `tutorials/multiple-root-modules-finished` directory for the same codebase that has been Terraplate'd. 71 | 72 | ```console title="Terraplate Structure" 73 | # Move into the finished tutorial 74 | cd tutorials/multiple-root-modules-finished 75 | 76 | # Check the files we have 77 | tree 78 | . 79 | ├── README.md 80 | ├── local 81 | │   ├── dev 82 | │   │   ├── main.tf 83 | │   │   └── terraplate.hcl 84 | │   ├── prod 85 | │   │   ├── main.tf 86 | │   │   └── terraplate.hcl 87 | │   └── terraplate.hcl 88 | ├── templates 89 | │   └── provider_local.tmpl 90 | └── terraplate.hcl 91 | ``` 92 | 93 | ### Resource files 94 | 95 | Let's inspect the `main.tf` files in the `local/dev` and `local/prod` environments. Note that these are identical and manually maintained (NOT currently generated by Terraplate). 96 | 97 | ```terraform title="local/dev/main.tf" 98 | --8<-- "tutorials/multiple-root-modules-finished/local/dev/main.tf" 99 | ``` 100 | 101 | ### Templates 102 | 103 | Currently we have two templates in the `templates/` directory. 104 | 105 | !!! info "`templates` directory is a convention" 106 | 107 | The `templates` directory is not required but it's a convention to keep the 108 | template files organized. Putting files in a `templates` does not mean 109 | or do anything: you still have to declare your templates using a `templates` 110 | block inside your Terrafiles. 111 | 112 | They will be processed by the Go templating engine so we could set values we want based on the Root Module where it should be templated. 113 | But for these simple files we don't need it. 114 | 115 | ```terraform title="templates/backend_local.tmpl" 116 | --8<-- "tutorials/multiple-root-modules-finished/templates/backend_local.tmpl" 117 | ``` 118 | 119 | ```terraform title="templates/provider_local.tmpl" 120 | --8<-- "tutorials/multiple-root-modules-finished/templates/provider_local.tmpl" 121 | ``` 122 | 123 | We need to declare these templates in our Terrafiles. The backend we want to use in *every* root module so we will declare it in the root Terrafile `terraplate.hcl`. 124 | The `local` provider we only want to use in the Terrafiles under the `local/` directory, so we place it in the `local/terraplate.hcl` Terrafile and all the child directories will inherit this template. 125 | 126 | That takes care of the backend and providers. 127 | 128 | ### Versions 129 | 130 | Defining the required Terraform version and `required_providers` everywhere is tiresome to do and maintain. 131 | With Terraplate we keep the required versions at each level in the directory structure where we need them, and the child directories inherit those. 132 | 133 | At the root level, `terraplate.hcl`, we define the Terraform CLI version. 134 | At the `local/terraplate.hcl` directory level we declare the `local` provider. 135 | 136 | ### Terrafiles 137 | 138 | ```terraform title="terraplate.hcl" 139 | --8<-- "tutorials/multiple-root-modules-finished/terraplate.hcl" 140 | ``` 141 | 142 | ```terraform title="local/terraplate.hcl" 143 | --8<-- "tutorials/multiple-root-modules-finished/local/terraplate.hcl" 144 | ``` 145 | 146 | ```terraform title="local/dev/terraplate.hcl" 147 | --8<-- "tutorials/multiple-root-modules-finished/local/dev/terraplate.hcl" 148 | ``` 149 | 150 | ```terraform title="local/prod/terraplate.hcl" 151 | --8<-- "tutorials/multiple-root-modules-finished/local/prod/terraplate.hcl" 152 | ``` 153 | 154 | ### Apply using Terraplate 155 | 156 | ```console title="Apply with Terraplate" 157 | # The parse command gives us a summary of the Root Modules (useful for debugging) 158 | terraplate parse 159 | 160 | # Let's build the templates 161 | terraplate build 162 | 163 | # Then we can plan (and init in the same run) 164 | terraplate plan --init 165 | 166 | # Finally apply the plans 167 | terraplate plan 168 | ``` 169 | 170 | ### Want to get even DRYer? 171 | 172 | The `main.tf` file is currently the same for the `dev` and `prod` environments. 173 | We could define a template for this, let's say under the `local/templates` directory. 174 | 175 | ```terraform title="local/templates/file.tmpl" 176 | resource "local_file" "dev" { 177 | # We can use Go templates to build the value right in the file if we want!! 178 | content = "env = {{ .Locals.environment }}" 179 | filename = "${path.module}/${local.environment}.txt" 180 | } 181 | ``` 182 | 183 | ```terraform title="local/terraplate.hcl" 184 | template "file" { 185 | contents = read_template("file.tmpl") 186 | } 187 | ``` 188 | 189 | ```console 190 | # Remove the files we are about to make DRY 191 | rm local/dev/main.tf local/prod/main.tf 192 | 193 | # Re-build to generate our new `file.tp.tf` files 194 | terraplate build 195 | 196 | # Plan and see that there should be no changes... 197 | terraplate plan 198 | ``` 199 | 200 | ## Summary 201 | 202 | We had a *single root module* with a *single state* that we separated into *two root modules* and therefore *two separate states*. 203 | We can now create many more root modules and the version, providers and backend are inherited and templated for us by Terraplate. 204 | Thus, the steps for creating a new root module, such as a `staging` would be as follows: 205 | 206 | ```console title="Creating a new Root Module" 207 | mkdir local/staging 208 | 209 | touch local/staging/terraplate.hcl 210 | ``` 211 | 212 | And something like the following in your `terraplate.hcl` file 213 | 214 | ```terraform title="local/staging/terraplate.hcl" 215 | 216 | locals { 217 | environment = "staging" 218 | } 219 | 220 | ``` 221 | 222 | Then just add your `.tf` files, or add some more templates, and away we go! 223 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Terraplate Examples 2 | 3 | Here are some Terraplate examples. 4 | -------------------------------------------------------------------------------- /examples/aws/stack/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | # Overwrite the environment variable 4 | environment = "prod" 5 | } 6 | -------------------------------------------------------------------------------- /examples/aws/templates/backend.tmpl: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | backend "s3" { 4 | bucket = "bucket-name" 5 | key = "{{ .RelativePath }}/terraform.tfstate" 6 | region = "{{ .Variables.aws_region }}" 7 | encrypt = true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/aws/templates/common.tmpl: -------------------------------------------------------------------------------- 1 | 2 | locals { 3 | common_tags = { 4 | Project = "{{ .Variables.project }}" 5 | Environment = "{{ .Variables.environment }}" 6 | Terraform = true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/aws/templates/providers.tmpl: -------------------------------------------------------------------------------- 1 | 2 | provider "aws" { 3 | region = var.aws_region 4 | 5 | default_tags { 6 | tags = local.common_tags 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/aws/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "backend" { 3 | contents = read_template("backend.tmpl") 4 | } 5 | 6 | template "providers" { 7 | contents = read_template("providers.tmpl") 8 | } 9 | template "common" { 10 | contents = read_template("common.tmpl") 11 | } 12 | 13 | variables { 14 | aws_region = "eu-west-1" 15 | environment = "global" 16 | project = "terraplate-aws-example" 17 | } 18 | 19 | exec { 20 | # Requires AWS auth to run, so skip running Terraform 21 | skip = true 22 | } 23 | 24 | terraform { 25 | required_version = ">= 1.0.0" 26 | 27 | required_providers { 28 | aws = { 29 | source = "hashicorp/aws" 30 | version = ">= 3.61.0" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/complete/templates/example.tmpl: -------------------------------------------------------------------------------- 1 | 2 | # Contents should be templated 3 | -------------------------------------------------------------------------------- /examples/complete/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | # Define a template that reads from a file 3 | template "example" { 4 | contents = read_template("example.tmpl") 5 | # Override the target where to write the file (default: example.tp.tf) 6 | target = "example_custom.tp.tf" 7 | condition = "{{ eq .Locals.key \"value\" }}" 8 | } 9 | 10 | # Define a template that embeds the content 11 | template "embedded" { 12 | contents = <<-EOL 13 | # Content here will be templated 14 | EOL 15 | } 16 | 17 | # Define terraform locals that can be used for templating and are written to the 18 | # terraplate.tf file 19 | locals { 20 | key = "value" 21 | other = { 22 | msg = "can also be an object" 23 | nested = { 24 | list = ["and an object", "with a list"] 25 | } 26 | } 27 | } 28 | 29 | # Define terraform variables that can be used for templating and are written to the 30 | # terraplate.tf file 31 | variables { 32 | key = "value" 33 | } 34 | 35 | # Define values that can be used for templating but are *not* written to any 36 | # terraform files 37 | values { 38 | key = "value" 39 | } 40 | 41 | # Define an exec block configuring how terraform is executed from terraplate 42 | exec { 43 | # Whether to skip running terraform. This is useful for disabling some root 44 | # modules but wanting to keep them in Git 45 | skip = false 46 | # Extra args arbitrary strings passed to terraform for each invocation 47 | extra_args = [] 48 | 49 | # Plan controls the behaviour for running terraform plan, and subsequntly 50 | # affects some of the terraform apply commands 51 | plan { 52 | # Whether to enable/disable inputs (terraform's -input option) 53 | input = false 54 | # Whether to hold a state lock (terraform's -lock option) 55 | lock = true 56 | # Name of terraform plan out file that can be used as input for terraform 57 | # apply (terraform's -out option) 58 | out = "tfplan" 59 | # Whether to skip producing an output. Default is false (i.e. use an out file) 60 | skip_out = false 61 | } 62 | } 63 | 64 | # Define a terraform block for providing things like required_providers which are 65 | # built into the terrafile.tf output file 66 | terraform { 67 | # Required providers are output to the terraplate.tf file 68 | required_providers { 69 | local = { 70 | source = "hashicorp/local" 71 | version = "2.1.0" 72 | } 73 | } 74 | 75 | required_version = ">= 1.1.0" 76 | } 77 | -------------------------------------------------------------------------------- /examples/config/README.md: -------------------------------------------------------------------------------- 1 | # Config Example 2 | 3 | This example demonstrates how reading from a YAML config file and setting some defaults can be achieved in a DRY way using Terraplate. 4 | -------------------------------------------------------------------------------- /examples/config/clusters/cluster-a/cluster.tp.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | cluster = "cluster-a" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config/clusters/cluster-a/prod/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | environment = "prod" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config/clusters/cluster-a/staging/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | environment = "staging" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config/clusters/cluster-b/cluster.tp.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | cluster = "cluster-b" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config/clusters/cluster-b/prod/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | environment = "prod" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config/clusters/cluster-b/staging/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | environment = "staging" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config/config.yaml: -------------------------------------------------------------------------------- 1 | clusters: 2 | cluster-a: 3 | prod: 4 | version: 1.21 5 | loadbalancer: 6 | - host: myapp.verifa.io 7 | staging: 8 | version: 1.21 9 | loadbalancer: 10 | - host: myapp-staging.verifa.io 11 | cluster-b: 12 | prod: 13 | version: 1.21 14 | staging: 15 | version: 1.21 16 | -------------------------------------------------------------------------------- /examples/config/templates/cluster.tmpl: -------------------------------------------------------------------------------- 1 | 2 | resource "local_file" "this" { 3 | content = jsonencode(local.cluster_config) 4 | filename = "${path.module}/cluster.json" 5 | } 6 | 7 | # Now use these values to invoke something like the EKS module: 8 | # https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest 9 | # module "eks" { 10 | # source = "terraform-aws-modules/eks/aws" 11 | # version = "18.2.3" 12 | 13 | # cluster_name = local.cluster_config.name 14 | # cluster_version = local.cluster_config.version 15 | # 16 | # ... more inputs here ... 17 | # } 18 | -------------------------------------------------------------------------------- /examples/config/templates/config.tmpl: -------------------------------------------------------------------------------- 1 | 2 | locals { 3 | # Read the root config file 4 | config = yamldecode(file("{{ .RootDir }}/config.yaml")) 5 | # Create the cluster config for a specific cluster based on name and environment 6 | cluster_config = merge( 7 | # Set any cluster defaults 8 | { 9 | name = "${var.cluster}-${var.environment}", 10 | loadbalancer = [] 11 | }, 12 | # Get the cluster config from the config YAML file, by looking for the 13 | # cluster name and environment, e.g. 14 | # clusters: 15 | # cluster_name: 16 | # environment: {} 17 | lookup(lookup(local.config.clusters, var.cluster), var.environment) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /examples/config/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "cluster" { 3 | contents = read_template("cluster.tmpl") 4 | } 5 | 6 | template "config" { 7 | contents = read_template("config.tmpl") 8 | } 9 | 10 | values { 11 | tfstate_file = "terraform.tfstate" 12 | } 13 | 14 | terraform { 15 | required_providers { 16 | local = { 17 | source = "hashicorp/local" 18 | version = "2.1.0" 19 | } 20 | } 21 | 22 | required_version = ">= 1.1.0" 23 | } 24 | -------------------------------------------------------------------------------- /examples/multiple-providers/terraplate.hcl: -------------------------------------------------------------------------------- 1 | # In this example we generate the Kubernetes provider blocks to work with 2 | # multiple clusters 3 | 4 | template "kubernetes-providers" { 5 | contents = <<-EOL 6 | {{ range $cluster, $value := .Values.eks_clusters }} 7 | data "aws_eks_cluster" "{{ $cluster }}" { 8 | name = "{{ $cluster }}" 9 | } 10 | 11 | data "aws_eks_cluster_auth" "{{ $cluster }}" { 12 | name = "{{ $cluster }}" 13 | } 14 | 15 | provider "kubernetes" { 16 | alias = "{{ $cluster }}" 17 | host = data.aws_eks_cluster.{{ $cluster }}.endpoint 18 | token = data.aws_eks_cluster_auth.{{ $cluster }}.token 19 | cluster_ca_certificate = base64decode(data.aws_eks_cluster.{{ $cluster }}.certificate_authority.0.data) 20 | } 21 | {{ end }} 22 | EOL 23 | } 24 | 25 | values { 26 | eks_clusters = { 27 | my_eks_cluster = { 28 | some_config = "whatever" 29 | } 30 | } 31 | } 32 | 33 | exec { 34 | skip = true 35 | } 36 | -------------------------------------------------------------------------------- /examples/nested/eu-west-1/dev/env.tp.hcl: -------------------------------------------------------------------------------- 1 | variables { 2 | environment = "dev" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nested/eu-west-1/dev/file/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "override" { 3 | contents = read_template("override.tmpl") 4 | } 5 | -------------------------------------------------------------------------------- /examples/nested/eu-west-1/prod/env.tp.hcl: -------------------------------------------------------------------------------- 1 | variables { 2 | environment = "prod" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nested/eu-west-1/prod/file/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "override" { 3 | contents = read_template("override.tmpl") 4 | } 5 | -------------------------------------------------------------------------------- /examples/nested/eu-west-1/region.tp.hcl: -------------------------------------------------------------------------------- 1 | variables { 2 | region = "eu-west-1" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nested/eu-west-1/templates/override.tmpl: -------------------------------------------------------------------------------- 1 | locals { 2 | region_level = true 3 | } 4 | -------------------------------------------------------------------------------- /examples/nested/templates/backend.tmpl: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | backend "local" { 4 | path = "{{ .Values.tfstate_file }}" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/nested/templates/main.tmpl: -------------------------------------------------------------------------------- 1 | resource "local_file" "this" { 2 | content = "env = ${var.environment}" 3 | filename = "${path.module}/env.txt" 4 | } 5 | -------------------------------------------------------------------------------- /examples/nested/templates/override.tmpl: -------------------------------------------------------------------------------- 1 | locals { 2 | root_level = true 3 | } 4 | -------------------------------------------------------------------------------- /examples/nested/templates/providers.tmpl: -------------------------------------------------------------------------------- 1 | 2 | provider "local" { 3 | # Configuration options 4 | } 5 | -------------------------------------------------------------------------------- /examples/nested/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | values { 3 | tfstate_file = "terraform.tfstate" 4 | } 5 | 6 | template "backend" { 7 | contents = read_template("backend.tmpl") 8 | } 9 | 10 | template "providers" { 11 | contents = read_template("providers.tmpl") 12 | } 13 | 14 | template "main" { 15 | contents = read_template("main.tmpl") 16 | } 17 | 18 | template "override" { 19 | contents = read_template("override.tmpl") 20 | } 21 | 22 | terraform { 23 | required_providers { 24 | local = { 25 | source = "hashicorp/local" 26 | version = "2.1.0" 27 | } 28 | } 29 | 30 | required_version = ">= 1.1.0" 31 | } 32 | -------------------------------------------------------------------------------- /examples/simple/dev/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | environment = "dev" 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple/prod/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | variables { 3 | environment = "prod" 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple/templates/backend.tmpl: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | backend "local" { 4 | path = "{{ .Values.tfstate_file }}" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/simple/templates/main.tmpl: -------------------------------------------------------------------------------- 1 | resource "local_file" "this" { 2 | content = "env = ${var.environment}" 3 | filename = "${path.module}/env.txt" 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple/templates/providers.tmpl: -------------------------------------------------------------------------------- 1 | 2 | provider "local" { 3 | # Configuration options 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | values { 3 | tfstate_file = "terraform.tfstate" 4 | } 5 | 6 | template "backend" { 7 | contents = read_template("backend.tmpl") 8 | } 9 | 10 | template "providers" { 11 | contents = read_template("providers.tmpl") 12 | } 13 | 14 | template "main" { 15 | contents = read_template("main.tmpl") 16 | } 17 | 18 | terraform { 19 | required_providers { 20 | local = { 21 | source = "hashicorp/local" 22 | version = "2.1.0" 23 | } 24 | } 25 | 26 | required_version = ">= 1.0.0" 27 | } 28 | -------------------------------------------------------------------------------- /examples/yaml/.gitignore: -------------------------------------------------------------------------------- 1 | *.yaml -------------------------------------------------------------------------------- /examples/yaml/templates/config.yaml.tmpl: -------------------------------------------------------------------------------- 1 | config: {{ .Locals.config }} -------------------------------------------------------------------------------- /examples/yaml/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | locals { 3 | config = "my-config" 4 | } 5 | 6 | template "config" { 7 | contents = read_template("config.yaml.tmpl") 8 | target = "config.yaml" 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/verifa/terraplate 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.2 7 | github.com/charmbracelet/bubbles v0.13.0 8 | github.com/charmbracelet/bubbletea v0.21.0 9 | github.com/charmbracelet/lipgloss v0.5.0 10 | github.com/fatih/color v1.13.0 11 | github.com/go-git/go-git/v5 v5.4.2 12 | github.com/hashicorp/go-multierror v1.1.1 13 | github.com/hashicorp/hcl/v2 v2.11.1 14 | github.com/hashicorp/terraform-json v0.14.0 15 | github.com/imdario/mergo v0.3.12 16 | github.com/muesli/termenv v0.12.0 17 | github.com/slack-go/slack v0.10.3 18 | github.com/spf13/cobra v1.3.0 19 | github.com/stretchr/testify v1.7.0 20 | github.com/zclconf/go-cty v1.10.0 21 | golang.org/x/text v0.3.7 22 | ) 23 | 24 | require ( 25 | github.com/Masterminds/goutils v1.1.1 // indirect 26 | github.com/Masterminds/semver/v3 v3.1.1 // indirect 27 | github.com/Microsoft/go-winio v0.4.16 // indirect 28 | github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect 29 | github.com/acomagu/bufpipe v1.0.3 // indirect 30 | github.com/agext/levenshtein v1.2.3 // indirect 31 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 32 | github.com/atotto/clipboard v0.1.4 // indirect 33 | github.com/containerd/console v1.0.3 // indirect 34 | github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect 35 | github.com/davecgh/go-spew v1.1.1 // indirect 36 | github.com/emirpasic/gods v1.12.0 // indirect 37 | github.com/go-git/gcfg v1.5.0 // indirect 38 | github.com/go-git/go-billy/v5 v5.3.1 // indirect 39 | github.com/google/go-cmp v0.5.8 // indirect 40 | github.com/google/uuid v1.1.2 // indirect 41 | github.com/gorilla/websocket v1.4.2 // indirect 42 | github.com/hashicorp/errwrap v1.0.0 // indirect 43 | github.com/hashicorp/go-version v1.5.0 // indirect 44 | github.com/huandu/xstrings v1.3.1 // indirect 45 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 46 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 47 | github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect 48 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 49 | github.com/mattn/go-colorable v0.1.12 // indirect 50 | github.com/mattn/go-isatty v0.0.14 // indirect 51 | github.com/mattn/go-runewidth v0.0.13 // indirect 52 | github.com/mitchellh/copystructure v1.2.0 // indirect 53 | github.com/mitchellh/go-homedir v1.1.0 // indirect 54 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 55 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 56 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 57 | github.com/muesli/cancelreader v0.2.0 // indirect 58 | github.com/muesli/reflow v0.3.0 // indirect 59 | github.com/pmezard/go-difflib v1.0.0 // indirect 60 | github.com/rivo/uniseg v0.2.0 // indirect 61 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 62 | github.com/sahilm/fuzzy v0.1.0 // indirect 63 | github.com/sergi/go-diff v1.1.0 // indirect 64 | github.com/shopspring/decimal v1.2.0 // indirect 65 | github.com/spf13/cast v1.4.1 // indirect 66 | github.com/spf13/pflag v1.0.5 // indirect 67 | github.com/xanzy/ssh-agent v0.3.0 // indirect 68 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect 69 | golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect 70 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect 71 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 72 | gopkg.in/warnings.v0 v0.1.2 // indirect 73 | gopkg.in/yaml.v2 v2.4.0 // indirect 74 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 75 | ) 76 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Verifa 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package main 17 | 18 | import "github.com/verifa/terraplate/cmd" 19 | 20 | //go:generate go run docs/gen.go 21 | 22 | func main() { 23 | cmd.Execute() 24 | } 25 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/verifa/terraplate/parser" 8 | "github.com/verifa/terraplate/runner" 9 | ) 10 | 11 | func TestMain(t *testing.T) { 12 | type test struct { 13 | dir string 14 | skipTerraform bool 15 | } 16 | tests := []test{ 17 | { 18 | dir: "examples/simple", 19 | }, 20 | { 21 | dir: "examples/simple/dev", 22 | }, 23 | { 24 | dir: "examples/complete", 25 | }, 26 | { 27 | dir: "examples/aws", 28 | }, 29 | { 30 | dir: "examples/aws/stack", 31 | }, 32 | { 33 | dir: "examples/config", 34 | }, 35 | { 36 | dir: "examples/nested", 37 | }, 38 | } 39 | for _, tc := range tests { 40 | t.Run(tc.dir, func(t *testing.T) { 41 | config, err := parser.Parse(&parser.Config{ 42 | Chdir: tc.dir, 43 | }) 44 | require.NoError(t, err) 45 | 46 | if !tc.skipTerraform { 47 | runner := runner.Run(config, 48 | runner.RunBuild(), 49 | runner.RunValidate(), 50 | runner.RunInit(), 51 | runner.RunPlan(), 52 | runner.RunApply()) 53 | 54 | require.NoError(t, runner.Errors()) 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: terraplate 2 | site_url: https://terraplate.verifa.io 3 | site_description: >- 4 | Terraplate is a thin wrapper around Terraform to keep things DRY and improve the developer experience. 5 | 6 | # Repository 7 | repo_name: verifa/terraplate 8 | repo_url: https://github.com/verifa/terraplate 9 | 10 | # Copyright 11 | copyright: Copyright © 2022 Verifa 12 | 13 | # Theme 14 | theme: 15 | name: material 16 | 17 | logo: assets/logo/terraplate-short-white.svg 18 | favicon: assets/favicon.png 19 | font: 20 | text: Outfit 21 | features: 22 | - navigation.tabs 23 | - content.code.annotate 24 | 25 | # Plugins 26 | plugins: 27 | - search 28 | - awesome-pages 29 | 30 | extra: 31 | social: 32 | - icon: fontawesome/brands/github 33 | link: https://github.com/verifa 34 | - icon: fontawesome/brands/docker 35 | link: https://hub.docker.com/r/verifa/terraplate/ 36 | 37 | extra_css: 38 | - stylesheets/theme.css 39 | 40 | markdown_extensions: 41 | - admonition 42 | - pymdownx.details 43 | - pymdownx.highlight: 44 | anchor_linenums: true 45 | - pymdownx.inlinehilite 46 | - pymdownx.snippets: 47 | check_paths: true 48 | - pymdownx.superfences 49 | - meta 50 | - toc: 51 | permalink: true 52 | -------------------------------------------------------------------------------- /notify/data.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | texttemplate "text/template" 8 | 9 | "github.com/verifa/terraplate/runner" 10 | ) 11 | 12 | var ( 13 | statusErrorColor = "#d2222d" 14 | statusDrftColor = "#ffbf00" 15 | statusSyncColor = "#238823" 16 | ) 17 | 18 | type Data struct { 19 | Runner *runner.Runner 20 | Repo *Repo 21 | ResultsURL string 22 | } 23 | 24 | func (d Data) StatusColor() string { 25 | switch { 26 | case d.Runner.HasError(): 27 | return statusErrorColor 28 | case d.Runner.HasDrift(): 29 | return statusDrftColor 30 | } 31 | return statusSyncColor 32 | } 33 | 34 | func execTemplate(text string, data *Data) ([]byte, error) { 35 | tmpl, tmplErr := texttemplate.New("notification"). 36 | Option("missingkey=error"). 37 | Parse(text) 38 | if tmplErr != nil { 39 | return nil, tmplErr 40 | } 41 | var rawContents bytes.Buffer 42 | if execErr := tmpl.Execute(&rawContents, data); execErr != nil { 43 | return nil, fmt.Errorf("executing template: %w", execErr) 44 | } 45 | return rawContents.Bytes(), nil 46 | } 47 | -------------------------------------------------------------------------------- /notify/notify.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/verifa/terraplate/runner" 7 | ) 8 | 9 | type notifyOptsFunc func(n *notifyOpts) 10 | 11 | type notifyOpts struct { 12 | Type NotifyType 13 | SlackConfig *SlackConfig 14 | 15 | Service Service 16 | NotifyFilter NotifyFilter 17 | } 18 | 19 | func WithNotify(notify NotifyType) notifyOptsFunc { 20 | return func(n *notifyOpts) { 21 | n.Type = notify 22 | } 23 | } 24 | 25 | func WithSlackConfig(config *SlackConfig) notifyOptsFunc { 26 | return func(n *notifyOpts) { 27 | n.SlackConfig = config 28 | } 29 | } 30 | 31 | func NotifyOn(filter NotifyFilter) notifyOptsFunc { 32 | return func(n *notifyOpts) { 33 | n.NotifyFilter = filter 34 | } 35 | } 36 | 37 | func New(opts ...notifyOptsFunc) (Service, error) { 38 | notifier := notifyOpts{ 39 | NotifyFilter: NotifyFilterAll, 40 | } 41 | for _, opt := range opts { 42 | opt(¬ifier) 43 | } 44 | 45 | if !notifier.NotifyFilter.IsValid() { 46 | return nil, fmt.Errorf("invalid notify filter: %s", notifier.NotifyFilter) 47 | } 48 | 49 | var ( 50 | service Service 51 | err error 52 | ) 53 | switch notifier.Type { 54 | case NotifyTypeSlack: 55 | service, err = newSlackService(notifier.SlackConfig, notifier.NotifyFilter) 56 | default: 57 | return nil, fmt.Errorf("unknown notification service type: %s", notifier.Type) 58 | } 59 | if err != nil { 60 | return nil, fmt.Errorf("creating service type %s: %w", notifier.Type, err) 61 | } 62 | return service, nil 63 | } 64 | 65 | type Service interface { 66 | Send(data *Data) error 67 | } 68 | 69 | type NotifyType string 70 | 71 | const ( 72 | NotifyTypeSlack NotifyType = "slack" 73 | ) 74 | 75 | type NotifyFilter string 76 | 77 | const ( 78 | NotifyFilterAll NotifyFilter = "all" 79 | NotifyFilterDrift NotifyFilter = "drift" 80 | ) 81 | 82 | // IsValid checks that the given string for a notification is valid 83 | func (f NotifyFilter) IsValid() bool { 84 | switch f { 85 | case NotifyFilterAll, NotifyFilterDrift: 86 | return true 87 | } 88 | return false 89 | } 90 | 91 | // ShouldNotify takes a runner and returns a bool whether the notification should 92 | // be sent, or not 93 | func (f NotifyFilter) ShouldNotify(runner *runner.Runner) bool { 94 | switch f { 95 | case NotifyFilterAll: 96 | return true 97 | case NotifyFilterDrift: 98 | if runner.HasError() || runner.HasDrift() { 99 | return true 100 | } 101 | } 102 | return false 103 | } 104 | -------------------------------------------------------------------------------- /notify/notify_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestSlack(t *testing.T) { 11 | 12 | os.Setenv(slackTokenEnv, "TEST") 13 | 14 | sc := DefaultSlackConfig() 15 | sc.Channel = "test" 16 | sc.SkipAuthTest = true 17 | 18 | _, notifyErr := New( 19 | WithNotify(NotifyTypeSlack), 20 | WithSlackConfig(sc), 21 | ) 22 | require.NoError(t, notifyErr) 23 | } 24 | -------------------------------------------------------------------------------- /notify/repo.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/verifa/terraplate/parser" 11 | ) 12 | 13 | var errRepoDetachedHead = errors.New("HEAD is detached") 14 | 15 | const ( 16 | repoNameEnv = "TP_REPO_NAME" 17 | repoBranchEnv = "TP_REPO_BRANCH" 18 | 19 | gitDefaultRemote = "origin" 20 | ) 21 | 22 | type repoOptFunc func(n *Repo) 23 | 24 | func WithRepo(repo Repo) repoOptFunc { 25 | return func(r *Repo) { 26 | *r = repo 27 | } 28 | } 29 | 30 | func WithRepoName(name string) repoOptFunc { 31 | return func(r *Repo) { 32 | r.Name = name 33 | } 34 | } 35 | 36 | func WithRepoBranch(branch string) repoOptFunc { 37 | return func(r *Repo) { 38 | r.Branch = branch 39 | } 40 | } 41 | 42 | type Repo struct { 43 | Name string 44 | Branch string 45 | } 46 | 47 | // LookupRepo gets the repository data for the drift detection. 48 | // It reads from environment variables (if set) otherwise fetches the information 49 | // from the local git repository 50 | func LookupRepo(opts ...repoOptFunc) (*Repo, error) { 51 | var repo Repo 52 | for _, opt := range opts { 53 | opt(&repo) 54 | } 55 | 56 | var ( 57 | hasRepoName = repo.Name != "" 58 | hasRepoBranch = repo.Branch != "" 59 | ) 60 | 61 | if !hasRepoName { 62 | repo.Name, hasRepoName = os.LookupEnv(repoNameEnv) 63 | } 64 | if !hasRepoBranch { 65 | repo.Branch, hasRepoBranch = os.LookupEnv(repoBranchEnv) 66 | } 67 | 68 | if hasRepoBranch && hasRepoName { 69 | return &repo, nil 70 | } 71 | 72 | gitRepo, gitErr := findGitRepo() 73 | if gitErr != nil { 74 | return nil, fmt.Errorf("reading git repo details: %w", gitErr) 75 | } 76 | 77 | if !hasRepoName { 78 | var remoteErr error 79 | repo.Name, remoteErr = repoRemote(gitRepo) 80 | if remoteErr != nil { 81 | return nil, remoteErr 82 | } 83 | } 84 | if !hasRepoBranch { 85 | var branchErr error 86 | repo.Branch, branchErr = repoBranch(gitRepo) 87 | if branchErr != nil { 88 | if !errors.Is(branchErr, errRepoDetachedHead) { 89 | return nil, branchErr 90 | } 91 | repo.Branch = "Detached HEAD" 92 | } 93 | } 94 | 95 | return &repo, nil 96 | } 97 | 98 | // findGitRepo returns the remote, branch and error (if any) 99 | func findGitRepo() (*git.Repository, error) { 100 | var gitRepo string 101 | travErr := parser.TraverseUpDirectory(".", func(dir string) (bool, error) { 102 | stat, statErr := os.Stat(filepath.Join(dir, ".git")) 103 | if statErr != nil { 104 | if os.IsNotExist(statErr) { 105 | // This is fine, so keep traversing and no error 106 | return true, nil 107 | } 108 | 109 | return false, statErr 110 | } 111 | if !stat.IsDir() { 112 | // This would be strange, but whatever, let's continue 113 | return true, nil 114 | } 115 | // We have found a .git directory... Let's use it 116 | gitRepo = dir 117 | // Gracefully stop the traversal 118 | return false, nil 119 | }) 120 | if travErr != nil { 121 | return nil, fmt.Errorf("looking for git repository: %w", travErr) 122 | } 123 | repo, openErr := git.PlainOpen(gitRepo) 124 | if openErr != nil { 125 | return nil, fmt.Errorf("opening git repo: %w", openErr) 126 | } 127 | return repo, nil 128 | } 129 | 130 | func repoRemote(repo *git.Repository) (string, error) { 131 | remote, remoteErr := repo.Remote(gitDefaultRemote) 132 | if remoteErr != nil { 133 | return "", fmt.Errorf("getting remote from repository: %w", remoteErr) 134 | } 135 | // Don't know if it's possible, but just to avoid a nasty bug 136 | if len(remote.Config().URLs) == 0 { 137 | return "", fmt.Errorf("git remote has no URLs") 138 | } 139 | remoteURL := remote.Config().URLs[0] 140 | 141 | return remoteURL, nil 142 | } 143 | 144 | func repoBranch(repo *git.Repository) (string, error) { 145 | head, headErr := repo.Head() 146 | if headErr != nil { 147 | return "", fmt.Errorf("getting HEAD from repository: %w", headErr) 148 | } 149 | 150 | if !head.Name().IsBranch() { 151 | return "", fmt.Errorf("getting branch from repository: %w", errRepoDetachedHead) 152 | } 153 | 154 | return head.Name().Short(), nil 155 | } 156 | -------------------------------------------------------------------------------- /notify/repo_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRepo(t *testing.T) { 12 | // Cannot really assert repo values as it depends on the origin value and branch... 13 | _, err := LookupRepo() 14 | require.NoError(t, err) 15 | } 16 | 17 | func TestRepoWithEnv(t *testing.T) { 18 | const ( 19 | testRepoName = "repoName" 20 | testRepoBranch = "repoBranch" 21 | ) 22 | os.Setenv(repoNameEnv, testRepoName) 23 | os.Setenv(repoBranchEnv, testRepoBranch) 24 | repo, err := LookupRepo() 25 | require.NoError(t, err) 26 | assert.Equal(t, testRepoName, repo.Name) 27 | assert.Equal(t, testRepoBranch, repo.Branch) 28 | } 29 | -------------------------------------------------------------------------------- /notify/slack.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | "github.com/slack-go/slack" 12 | ) 13 | 14 | const ( 15 | slackTokenEnv = "SLACK_API_TOKEN" 16 | slackChannelEnv = "SLACK_CHANNEL" 17 | slackTemplateEnv = "SLACK_TEMPLATE" 18 | slackSkipAuthEnv = "SLACK_SKIP_AUTH" 19 | ) 20 | 21 | //go:embed templates/slack.tmpl 22 | var slackTemplate string 23 | 24 | var ( 25 | notifyColor = color.New(color.FgMagenta, color.Bold) 26 | notifySkipColor = color.New(color.FgCyan, color.Bold) 27 | notifySlackMessage = notifyColor.Sprint("\nSending Slack notification...\n\n") 28 | notifyNoDriftMessage = notifySkipColor.Sprint("\nNo drift detected. Skipping notification.\n\n") 29 | ) 30 | 31 | var _ Service = (*slackService)(nil) 32 | 33 | func DefaultSlackConfig() *SlackConfig { 34 | var c SlackConfig 35 | c.Channel = os.Getenv(slackChannelEnv) 36 | c.TemplateFile = os.Getenv(slackTemplateEnv) 37 | _, c.SkipAuthTest = os.LookupEnv(slackSkipAuthEnv) 38 | return &c 39 | } 40 | 41 | type SlackConfig struct { 42 | // Channel is the slack channel where to send the notification 43 | Channel string 44 | // Template is an optional string that will be templated into JSON for 45 | // Slack's message options. 46 | // A default template will be used if empty 47 | TemplateFile string 48 | // SkipAuthTest defines whether to skip the Slack auth test when creating 49 | // the service or not 50 | SkipAuthTest bool 51 | } 52 | 53 | func newSlackService(config *SlackConfig, filter NotifyFilter) (Service, error) { 54 | if config == nil { 55 | return nil, fmt.Errorf("cannot create Slack service with a nil configuration") 56 | } 57 | token, ok := os.LookupEnv(slackTokenEnv) 58 | if !ok { 59 | return nil, fmt.Errorf("`%s` env must be set", slackTokenEnv) 60 | } 61 | client := slack.New(token) 62 | if !config.SkipAuthTest { 63 | _, authErr := client.AuthTest() 64 | if authErr != nil { 65 | return nil, fmt.Errorf("auth test failed: %w", authErr) 66 | } 67 | } 68 | 69 | // Validate the slack config 70 | if config.Channel == "" { 71 | return nil, fmt.Errorf("must provide a slack channel") 72 | } 73 | 74 | return &slackService{ 75 | Client: client, 76 | SlackConfig: config, 77 | NotifyFilter: filter, 78 | }, nil 79 | } 80 | 81 | type slackService struct { 82 | Client *slack.Client 83 | SlackConfig *SlackConfig 84 | NotifyFilter NotifyFilter 85 | } 86 | 87 | type slackMsgJSON struct { 88 | Attachments []slack.Attachment `json:"attachments,omitempty"` 89 | } 90 | 91 | func (s *slackService) Send(data *Data) error { 92 | if !s.NotifyFilter.ShouldNotify(data.Runner) { 93 | fmt.Print(notifyNoDriftMessage) 94 | return nil 95 | } 96 | fmt.Print(notifySlackMessage) 97 | msgOpts, optErr := s.messageOptions(data) 98 | if optErr != nil { 99 | return fmt.Errorf("creating slack message options: %w", optErr) 100 | } 101 | _, _, _, sendErr := s.Client.SendMessage(s.SlackConfig.Channel, msgOpts...) 102 | if sendErr != nil { 103 | return fmt.Errorf("sending slack message: %s", sendErr) 104 | } 105 | return nil 106 | } 107 | 108 | func (s *slackService) messageOptions(data *Data) ([]slack.MsgOption, error) { 109 | // First we need to execute the template 110 | var tmplContent = slackTemplate 111 | if s.SlackConfig.TemplateFile != "" { 112 | tmplContent = s.SlackConfig.TemplateFile 113 | } 114 | 115 | tmplBytes, tmplErr := execTemplate(tmplContent, data) 116 | if tmplErr != nil { 117 | return nil, fmt.Errorf("templating slack message: %w", tmplErr) 118 | } 119 | 120 | var msgOpts []slack.MsgOption 121 | // Set the message text 122 | now := time.Now() 123 | msgOpts = append(msgOpts, slack.MsgOptionText(fmt.Sprintf("Terraplate drift on %s", now.Format(time.RFC3339)), false)) 124 | 125 | var slackMsg slackMsgJSON 126 | if err := json.Unmarshal(tmplBytes, &slackMsg); err != nil { 127 | return nil, fmt.Errorf("unmarshalling slack message: %w", err) 128 | } 129 | if slackMsg.Attachments == nil { 130 | return nil, fmt.Errorf("slack message must have attachments") 131 | } 132 | msgOpts = append(msgOpts, slack.MsgOptionAttachments(slackMsg.Attachments...)) 133 | return msgOpts, nil 134 | } 135 | -------------------------------------------------------------------------------- /notify/templates/slack.tmpl: -------------------------------------------------------------------------------- 1 | {{- $runsWithError := .Result.RunsWithError }} 2 | {{- $runsWithDrift := .Result.RunsWithDrift }} 3 | {{- $summary := "*Summary*\\n" }} 4 | {{- $errorSummary := printf "Error encountered in %d root modules\\n" (len $runsWithError) }} 5 | {{- $driftSummary := printf "Drift detected in %d root modules\\n" (len $runsWithDrift) }} 6 | {{- if len $runsWithError }} 7 | {{- $summary = printf "%s%s" $summary $errorSummary }} 8 | {{- end }} 9 | {{- if len $runsWithDrift }} 10 | {{- $summary = printf "%s%s" $summary $driftSummary }} 11 | {{- end }} 12 | {{- if not (len $runsWithDrift) }} 13 | {{- $summary = printf "%sNo drift detected\\n" $summary }} 14 | {{- end }} 15 | {{- if .ResultsURL }} 16 | {{- $summary = printf "%s\\nSee results <%s>" $summary .ResultsURL }} 17 | {{- end }} 18 | { 19 | "attachments": [ 20 | { 21 | "color": "{{ .StatusColor }}", 22 | "blocks": [ 23 | { 24 | "type": "section", 25 | "fields": [ 26 | { 27 | "type": "mrkdwn", 28 | "text": "*Repository*\n{{ .Repo.Name }}" 29 | }, 30 | { 31 | "type": "mrkdwn", 32 | "text": "*Branch*\n{{ .Repo.Branch }}" 33 | } 34 | ] 35 | }, 36 | { 37 | "type": "section", 38 | "text": { 39 | "type": "mrkdwn", 40 | "text": "{{ $summary }}" 41 | } 42 | } 43 | ] 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /parser/data.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | // BuildData defines the data which is passed to the Go template engine 4 | type BuildData struct { 5 | Locals map[string]interface{} 6 | Variables map[string]interface{} 7 | Values map[string]interface{} 8 | Terrafile *Terrafile 9 | // RelativeDir is the relative directory from the root Terrafile to the 10 | // Terrafile being built 11 | RelativeDir string 12 | // RelativePath is the relative path from the root Terrafile to the Terrafile 13 | // being built 14 | RelativePath string 15 | // RelativeRootDir is the relative directory of the root Terrafile 16 | RelativeRootDir string 17 | // RootDir is the absolute directory of the root Terrafile 18 | RootDir string 19 | } 20 | -------------------------------------------------------------------------------- /parser/evalctx.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/zclconf/go-cty/cty" 10 | "github.com/zclconf/go-cty/cty/function" 11 | ) 12 | 13 | func evalCtx(dir string) *hcl.EvalContext { 14 | return &hcl.EvalContext{ 15 | Functions: map[string]function.Function{ 16 | "read_template": readTemplateFunc(dir), 17 | }, 18 | } 19 | } 20 | 21 | // readTemplateFunc creates an HCL function that will read the contents of a 22 | // template file by the given name, starting at the directory provided. 23 | // It will first check for the template file within a "templates" directory 24 | // (if it exists), and then in the root of the given directory. 25 | // It will traverse up directories until it finds a template with that name 26 | // and return the contents of the first match that it finds. 27 | // If no template is found, it returns an error 28 | func readTemplateFunc(dir string) function.Function { 29 | return function.New(&function.Spec{ 30 | Params: []function.Parameter{ 31 | { 32 | Name: "file", 33 | Type: cty.String, 34 | }, 35 | }, 36 | Type: function.StaticReturnType(cty.String), 37 | Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { 38 | file := args[0].AsString() 39 | 40 | var ( 41 | contents string 42 | found bool 43 | readErr error 44 | ) 45 | travErr := TraverseUpDirectory(dir, func(travDir string) (bool, error) { 46 | var path string 47 | // Check if there is a template file first in the templates 48 | // directory, and then in the directory we are traversing 49 | path = filepath.Join(travDir, "templates", file) 50 | contents, found, readErr = readTemplate(path) 51 | if readErr != nil { 52 | return false, readErr 53 | } 54 | if found { 55 | // Indicate not to continue traversing 56 | return false, nil 57 | } 58 | 59 | // Try again without the templates directory 60 | path = filepath.Join(travDir, file) 61 | contents, found, readErr = readTemplate(path) 62 | if readErr != nil { 63 | return false, readErr 64 | } 65 | if found { 66 | // Indicate not to continue traversing 67 | return false, nil 68 | } 69 | // If not found, and no errors, continue traversing 70 | return true, nil 71 | }) 72 | if travErr != nil { 73 | return cty.NilVal, fmt.Errorf("") 74 | } 75 | 76 | if !found { 77 | return cty.NilVal, fmt.Errorf("could not find template %s", file) 78 | } 79 | 80 | return cty.StringVal(contents), nil 81 | }, 82 | }) 83 | } 84 | 85 | func readTemplate(path string) (string, bool, error) { 86 | bytes, readErr := os.ReadFile(path) 87 | if readErr != nil { 88 | if !os.IsNotExist(readErr) { 89 | return "", false, fmt.Errorf("reading file %s: %w", path, readErr) 90 | } 91 | return "", false, nil 92 | } 93 | return string(bytes), true, nil 94 | } 95 | -------------------------------------------------------------------------------- /parser/exec.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | type ExecBlock struct { 4 | Skip bool `hcl:"skip,optional"` 5 | ExtraArgs []string `hcl:"extra_args,optional"` 6 | PlanBlock *ExecPlanBlock `hcl:"plan,block"` 7 | } 8 | 9 | // ExecPlanBlock defines the arguments for running the Terraform plan 10 | type ExecPlanBlock struct { 11 | Input bool `hcl:"input,optional"` 12 | Lock bool `hcl:"lock,optional"` 13 | // Out specifies the name of the Terraform plan file to create (if any) 14 | Out string `hcl:"out,optional"` 15 | // SkipOut says whether to avoid creating a plan file or not. Useful in cases 16 | // such as running Terraform Cloud remotely, where plans cannot be created. 17 | SkipOut bool `hcl:"skip_out,optional"` 18 | } 19 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func DefaultConfig() *Config { 12 | return &Config{ 13 | Chdir: ".", 14 | } 15 | } 16 | 17 | type Config struct { 18 | Chdir string 19 | } 20 | 21 | func Parse(config *Config) (*TerraConfig, error) { 22 | // Check that the directory exists and is a directory 23 | dirStat, statErr := os.Stat(config.Chdir) 24 | if statErr != nil { 25 | return nil, statErr 26 | } 27 | if !dirStat.IsDir() { 28 | return nil, fmt.Errorf("given directory is not a directory: %s", config.Chdir) 29 | } 30 | 31 | ancestor, travErr := walkUpDirectory(config.Chdir) 32 | if travErr != nil { 33 | return nil, fmt.Errorf("looking for parent terraplate.hcl files: %w", travErr) 34 | } 35 | 36 | terrafiles, walkErr := walkDownDirectory(config.Chdir, ancestor) 37 | if walkErr != nil { 38 | return nil, fmt.Errorf("looking for terraplate.hcl files: %w", walkErr) 39 | } 40 | 41 | // Check if any terrafiles were found. If not, return an error 42 | if len(terrafiles) == 0 { 43 | return nil, errors.New("no terraplate files found") 44 | } 45 | 46 | // Get the absolute path to set as working directory 47 | absPath, absErr := filepath.Abs(config.Chdir) 48 | if absErr != nil { 49 | return nil, fmt.Errorf("getting absolute path for working directory %s: %w", config.Chdir, absErr) 50 | } 51 | 52 | tfc := TerraConfig{ 53 | Terrafiles: terrafiles, 54 | WorkingDirectory: absPath, 55 | } 56 | 57 | // Terrafiles inherit values from ancestors. Let's resolve the root modules 58 | // so that they are ready for building/executing 59 | if err := tfc.MergeTerrafiles(); err != nil { 60 | return nil, fmt.Errorf("resolving inheritance: %w", err) 61 | } 62 | 63 | return &tfc, nil 64 | } 65 | 66 | // TraverseUpDirectory takes a path and a callback function. 67 | // For each directory that is an ancestor of path (and including path), call the 68 | // callback function. 69 | // Callback returns an error if something went wrong (which stops the traversal) 70 | // and a boolean: if true is returned the traversal continues, if false the traversal 71 | // finishes gracefully 72 | func TraverseUpDirectory(path string, visit func(dir string) (bool, error)) error { 73 | if !filepath.IsAbs(path) { 74 | var pathErr error 75 | path, pathErr = filepath.Abs(path) 76 | if pathErr != nil { 77 | return fmt.Errorf("cannot get absolute path for %s: %w", path, pathErr) 78 | } 79 | } 80 | 81 | fileInfo, statErr := os.Stat(path) 82 | if statErr != nil { 83 | return statErr 84 | } 85 | if !fileInfo.IsDir() { 86 | path = filepath.Dir(path) 87 | } 88 | 89 | // Check if we cannot traverse any higher up. If so, return 90 | for path != filepath.Dir(path) { 91 | proceed, err := visit(path) 92 | if err != nil { 93 | return err 94 | } 95 | // If false was returned, indicating not to proceed, then return 96 | if !proceed { 97 | return nil 98 | } 99 | path = filepath.Dir(path) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func walkUpDirectory(path string) (*Terrafile, error) { 106 | var ( 107 | skipFirst = false 108 | childTerrafile *Terrafile 109 | ) 110 | travErr := TraverseUpDirectory(path, func(dir string) (bool, error) { 111 | // Skip the first directory as it will get processed when we walk down 112 | // the directory structure 113 | if !skipFirst { 114 | skipFirst = true 115 | return true, nil 116 | } 117 | 118 | entries, readErr := os.ReadDir(dir) 119 | if readErr != nil { 120 | return false, fmt.Errorf("reading directory \"%s\": %w", dir, readErr) 121 | } 122 | 123 | var terrafile *Terrafile 124 | // Iterate over files and check if any of them are Terrafiles. 125 | // If they are, parse them. If there's multiple, it's an error (for now). 126 | for _, entry := range entries { 127 | if !entry.IsDir() && isTerraplateFile(entry.Name()) { 128 | // Check that we haven't already detected a terrafile. 129 | // Multiple terrafiles are not allowed at this time. 130 | if terrafile != nil { 131 | return false, fmt.Errorf("multiple terraplate files detected in folder %s", dir) 132 | } 133 | var ( 134 | parseErr error 135 | path = filepath.Join(dir, entry.Name()) 136 | ) 137 | terrafile, parseErr = parseTerrafile(path) 138 | if parseErr != nil { 139 | return false, fmt.Errorf("parsing terraplate file %s: %w", path, parseErr) 140 | } 141 | } 142 | } 143 | 144 | // If no terrafile was found, just continue traversing up 145 | if terrafile == nil { 146 | return true, nil 147 | } 148 | if childTerrafile != nil { 149 | // Terrafile is not a root module because it has a child 150 | terrafile.IsRoot = false 151 | // Create parent/child relationship 152 | terrafile.Children = append(terrafile.Children, childTerrafile) 153 | childTerrafile.Ancestor = terrafile 154 | } 155 | // Set current terrafile as the new child terrafile for the next traversal 156 | childTerrafile = terrafile 157 | 158 | return true, nil 159 | }) 160 | if travErr != nil { 161 | return nil, fmt.Errorf("walking up directories: %w", travErr) 162 | } 163 | if childTerrafile != nil { 164 | rootMods := childTerrafile.rootModules() 165 | if len(rootMods) == 1 { 166 | return rootMods[0], nil 167 | } 168 | 169 | return nil, fmt.Errorf("unexpected: terrafile has more than one root module after walking up directories, total %d", len(rootMods)) 170 | } 171 | // Returning nil is ok. It means we did not find any terrafiles but also did 172 | // not encounter an error 173 | return nil, nil 174 | } 175 | 176 | func walkDownDirectory(dir string, ancestor *Terrafile) ([]*Terrafile, error) { 177 | var ( 178 | terrafiles []*Terrafile 179 | terrafile *Terrafile 180 | subDirs []string 181 | ) 182 | 183 | // Skip the .terraform directories 184 | if filepath.Base(dir) == ".terraform" { 185 | return terrafiles, nil 186 | } 187 | entries, readErr := os.ReadDir(dir) 188 | if readErr != nil { 189 | return nil, fmt.Errorf("reading directory \"%s\": %w", dir, readErr) 190 | } 191 | for _, entry := range entries { 192 | if entry.IsDir() { 193 | subDirs = append(subDirs, filepath.Join(dir, entry.Name())) 194 | continue 195 | } 196 | if isTerraplateFile(entry.Name()) { 197 | // Check that we haven't already detected a terrafile. 198 | // Multiple terrafiles are not allowed at this time. 199 | if terrafile != nil { 200 | return nil, fmt.Errorf("multiple terraplate files detected in folder %s", dir) 201 | } 202 | var ( 203 | parseErr error 204 | path = filepath.Join(dir, entry.Name()) 205 | ) 206 | terrafile, parseErr = parseTerrafile(path) 207 | if parseErr != nil { 208 | return nil, fmt.Errorf("parsing terraplate file %s: %w", path, parseErr) 209 | } 210 | if ancestor != nil { 211 | ancestor.IsRoot = false 212 | terrafile.Ancestor = ancestor 213 | ancestor.Children = append(ancestor.Children, terrafile) 214 | } 215 | 216 | terrafiles = append(terrafiles, terrafile) 217 | } 218 | } 219 | if terrafile == nil { 220 | terrafile = ancestor 221 | } 222 | for _, subDir := range subDirs { 223 | descFiles, err := walkDownDirectory(subDir, terrafile) 224 | if err != nil { 225 | return nil, err 226 | } 227 | terrafiles = append(terrafiles, descFiles...) 228 | } 229 | return terrafiles, nil 230 | } 231 | 232 | func isTerraplateFile(name string) bool { 233 | return name == "terraplate.hcl" || strings.HasSuffix(name, ".tp.hcl") 234 | } 235 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParser(t *testing.T) { 11 | // Simply test parsing all the examples... 12 | _, err := Parse(&Config{ 13 | Chdir: "../examples/nested", 14 | }) 15 | require.NoError(t, err) 16 | } 17 | 18 | func TestTraverse(t *testing.T) { 19 | config, err := Parse(&Config{ 20 | Chdir: "testdata/nested/1/2", 21 | }) 22 | require.NoError(t, err) 23 | // There should be only one root module 24 | require.Len(t, config.RootModules(), 1) 25 | 26 | rootMod := config.RootModules()[0] 27 | 28 | var ( 29 | upPath = make([]*Terrafile, 0) 30 | downPath = make([]*Terrafile, 0) 31 | ) 32 | 33 | rootMod.traverseAncestors(func(ancestor *Terrafile) error { 34 | upPath = append(upPath, ancestor) 35 | return nil 36 | }) 37 | rootMod.traverseAncestorsReverse(func(ancestor *Terrafile) error { 38 | // Reverse the down path, which should equal the up path 39 | downPath = append([]*Terrafile{ancestor}, downPath...) 40 | return nil 41 | }) 42 | 43 | assert.Equal(t, upPath, downPath) 44 | } 45 | 46 | func TestOverride(t *testing.T) { 47 | _, err := Parse(&Config{ 48 | Chdir: "testdata/override", 49 | }) 50 | require.NoError(t, err) 51 | } 52 | 53 | func TestTemplates(t *testing.T) { 54 | _, err := Parse(&Config{ 55 | Chdir: "testdata/testTemplates", 56 | }) 57 | require.NoError(t, err) 58 | } 59 | -------------------------------------------------------------------------------- /parser/template.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/Masterminds/sprig/v3" 12 | "github.com/hashicorp/hcl/v2/hclwrite" 13 | ) 14 | 15 | // TerraTemplate defines the template{} block within a Terrafile 16 | type TerraTemplate struct { 17 | Name string `hcl:",label"` 18 | Contents string `hcl:"contents,attr"` 19 | // Target defines the target file to generate. 20 | // Defaults to the Name of the template with a ".tp.tf" extension 21 | Target string `hcl:"target,optional"` 22 | // ConditionAttr defines a string boolean that specifies whether this template 23 | // should be built or not. 24 | // The string can include Go templates, which means you can have dynamic 25 | // behaviour based on the Terrafile 26 | ConditionAttr string `hcl:"condition,optional"` 27 | } 28 | 29 | // Condition resolves the condition attribute to a boolean, or error. 30 | // Errors can occur if either the templating errored or the conversion from string 31 | // to bool is not possible. 32 | func (t TerraTemplate) Condition(data *BuildData) (bool, error) { 33 | // If not set, the default is true (to build) 34 | if t.ConditionAttr == "" { 35 | return true, nil 36 | } 37 | // First tempalte it 38 | contents, execErr := ExecTemplate(data, "condition", t.ConditionAttr) 39 | if execErr != nil { 40 | return false, fmt.Errorf("templating condition for template %s: %w", t.Name, execErr) 41 | } 42 | condition, parseErr := strconv.ParseBool(contents.String()) 43 | if parseErr != nil { 44 | return false, fmt.Errorf("converting condition string to bool for template %s: %w", t.Name, parseErr) 45 | } 46 | return condition, nil 47 | } 48 | 49 | func TemplateWrite(buildData *BuildData, name string, text string, target string) error { 50 | rawContents, execErr := ExecTemplate(buildData, name, text) 51 | if execErr != nil { 52 | return execErr 53 | } 54 | 55 | var formattedContents []byte 56 | if strings.HasSuffix(target, ".tf") { 57 | // Format the contents to make it nice HCL 58 | formattedContents = hclwrite.Format(rawContents.Bytes()) 59 | } else { 60 | formattedContents = rawContents.Bytes() 61 | } 62 | 63 | file, createErr := os.Create(target) 64 | if createErr != nil { 65 | return fmt.Errorf("creating file %s: %w", target, createErr) 66 | } 67 | defer file.Close() 68 | if _, writeErr := file.Write(formattedContents); writeErr != nil { 69 | return fmt.Errorf("writing file %s: %w", target, writeErr) 70 | } 71 | return nil 72 | } 73 | 74 | func ExecTemplate(buildData *BuildData, name string, text string) (*bytes.Buffer, error) { 75 | tmpl, tmplErr := commonTemplate(name).Parse(text) 76 | if tmplErr != nil { 77 | return nil, tmplErr 78 | } 79 | 80 | var contents bytes.Buffer 81 | if execErr := tmpl.Execute(&contents, buildData); execErr != nil { 82 | return nil, fmt.Errorf("executing template: %w", execErr) 83 | } 84 | return &contents, nil 85 | } 86 | 87 | func commonTemplate(name string) *template.Template { 88 | return template.New(name). 89 | Option("missingkey=error"). 90 | Funcs(sprig.TxtFuncMap()) 91 | } 92 | -------------------------------------------------------------------------------- /parser/terraconfig.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/imdario/mergo" 7 | ) 8 | 9 | type TerraConfig struct { 10 | Terrafiles []*Terrafile 11 | WorkingDirectory string 12 | } 13 | 14 | // RootModules returns the Terrafiles that are considered root modules 15 | // and should therefore be processed 16 | func (c *TerraConfig) RootModules() []*Terrafile { 17 | var files = make([]*Terrafile, 0) 18 | for _, tf := range c.Terrafiles { 19 | if tf.IsRoot { 20 | files = append(files, tf) 21 | } 22 | } 23 | return files 24 | } 25 | 26 | // RootTerrafile returns the top-most (root) Terrafiles. 27 | // It's possible that there's multiple root terrafiles if there are multiple 28 | // trees of terrafiles that have been parsed 29 | func (c *TerraConfig) RootTerrafiles() []*Terrafile { 30 | if len(c.Terrafiles) == 0 { 31 | // Not possible as an empty list of terrafiles should through an error 32 | // during parsing 33 | return nil 34 | } 35 | // Create a map of unique root terrafiles 36 | var tfMap = make(map[string]*Terrafile) 37 | for _, tf := range c.Terrafiles { 38 | rootTf := tf.rootAncestor() 39 | // Doesn't matter if we overwrite as it's the same root terrafile 40 | tfMap[rootTf.Path] = rootTf 41 | } 42 | var rootTfs = make([]*Terrafile, 0, len(tfMap)) 43 | for _, tf := range tfMap { 44 | rootTfs = append(rootTfs, tf) 45 | } 46 | return rootTfs 47 | } 48 | 49 | func (c *TerraConfig) MergeTerrafiles() error { 50 | // Logic is to get the topmost (root) Terrafile and traverse down the tree 51 | // merging parent with child terrafiles as we go 52 | rootTfs := c.RootTerrafiles() 53 | 54 | for _, rootTf := range rootTfs { 55 | // Set defaults for root terrafile 56 | if err := mergo.Merge(rootTf, DefaultTerrafile); err != nil { 57 | return fmt.Errorf("setting defaults for root terrafile %s: %w", rootTf.Path, err) 58 | } 59 | 60 | travErr := rootTf.traverseChildren(func(parent *Terrafile, tf *Terrafile) error { 61 | if err := tf.mergeTerrafile(parent); err != nil { 62 | return fmt.Errorf("merging terrafile %s with parent %s: %w", tf.Path, parent.Path, err) 63 | } 64 | return nil 65 | }) 66 | if travErr != nil { 67 | return fmt.Errorf("traversing terrafiles from root %s: %w", rootTf.Path, travErr) 68 | } 69 | } 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /parser/testdata/nested/1/2/terraplate.hcl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/parser/testdata/nested/1/2/terraplate.hcl -------------------------------------------------------------------------------- /parser/testdata/nested/1/2/terraplate.tf: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 3 | # 4 | # Terrafile: nested/1/2/terraplate.hcl 5 | 6 | terraform { 7 | required_version = ">= 1.1.0" 8 | 9 | required_providers { 10 | local = { 11 | source = "hashicorp/local" 12 | version = "2.1.0" 13 | } 14 | } 15 | } 16 | 17 | locals { 18 | key = "value" 19 | } 20 | 21 | variable "key" { 22 | default = "value" 23 | } 24 | 25 | -------------------------------------------------------------------------------- /parser/testdata/nested/1/terraplate.hcl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/parser/testdata/nested/1/terraplate.hcl -------------------------------------------------------------------------------- /parser/testdata/override/a/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | exec { 3 | extra_args = ["a", "b", "c"] 4 | 5 | plan { 6 | input = true 7 | lock = false 8 | out = "outoverride" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /parser/testdata/override/a/terraplate.tf: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 3 | # 4 | # Terrafile: override/a/terraplate.hcl 5 | 6 | terraform { 7 | required_version = ">= 1.1.0" 8 | 9 | required_providers { 10 | local = { 11 | source = "hashicorp/local" 12 | version = "2.1.0" 13 | } 14 | } 15 | } 16 | 17 | locals { 18 | key = "value" 19 | } 20 | 21 | variable "key" { 22 | default = "value" 23 | } 24 | 25 | -------------------------------------------------------------------------------- /parser/testdata/override/b/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /parser/testdata/override/b/terraplate.tf: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 3 | # 4 | # Terrafile: override/b/terraplate.hcl 5 | 6 | terraform { 7 | required_version = ">= 1.1.0" 8 | 9 | required_providers { 10 | local = { 11 | source = "hashicorp/local" 12 | version = "2.1.0" 13 | } 14 | } 15 | } 16 | 17 | locals { 18 | key = "value" 19 | } 20 | 21 | variable "key" { 22 | default = "value" 23 | } 24 | 25 | -------------------------------------------------------------------------------- /parser/testdata/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | locals { 3 | key = "value" 4 | } 5 | 6 | variables { 7 | key = "value" 8 | } 9 | 10 | values { 11 | key = "value" 12 | } 13 | 14 | exec { 15 | skip = true 16 | extra_args = [] 17 | 18 | plan { 19 | input = false 20 | lock = true 21 | out = "tfplan" 22 | skip_out = false 23 | } 24 | } 25 | 26 | terraform { 27 | required_providers { 28 | local = { 29 | source = "hashicorp/local" 30 | version = "2.1.0" 31 | } 32 | } 33 | 34 | required_version = ">= 1.1.0" 35 | } 36 | -------------------------------------------------------------------------------- /parser/testdata/testTemplates/nested/config.tp.tf: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 3 | # 4 | # Terrafile: testTemplates/nested/terraplate.hcl 5 | # Template: config 6 | 7 | -------------------------------------------------------------------------------- /parser/testdata/testTemplates/nested/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "config" { 3 | # contents = read_template("config.tmpl") 4 | contents = read_template("config.tmpl") 5 | } 6 | -------------------------------------------------------------------------------- /parser/testdata/testTemplates/nested/terraplate.tf: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: THIS FILE WAS AUTOMATICALLY GENERATED BY TERRAPLATE 3 | # 4 | # Terrafile: testTemplates/nested/terraplate.hcl 5 | 6 | terraform { 7 | required_version = ">= 1.1.0" 8 | 9 | required_providers { 10 | local = { 11 | source = "hashicorp/local" 12 | version = "2.1.0" 13 | } 14 | } 15 | } 16 | 17 | locals { 18 | key = "value" 19 | } 20 | 21 | variable "key" { 22 | default = "value" 23 | } 24 | 25 | -------------------------------------------------------------------------------- /parser/testdata/testTemplates/templates/config.tmpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/parser/testdata/testTemplates/templates/config.tmpl -------------------------------------------------------------------------------- /parser/testdata/testTemplates/terraplate.hcl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/parser/testdata/testTemplates/terraplate.hcl -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # MKDocs requirements 2 | mkdocs-material 3 | mkdocs-awesome-pages-plugin -------------------------------------------------------------------------------- /runner/cmds.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os/exec" 8 | "time" 9 | 10 | "github.com/verifa/terraplate/builder" 11 | "github.com/verifa/terraplate/parser" 12 | "golang.org/x/text/cases" 13 | "golang.org/x/text/language" 14 | ) 15 | 16 | type terraCmd string 17 | 18 | func (t terraCmd) Action() string { 19 | switch t { 20 | case terraBuild: 21 | return "building" 22 | case terraValidate: 23 | return "validating" 24 | case terraInit: 25 | return "initializing" 26 | case terraPlan: 27 | return "planning" 28 | case terraApply: 29 | return "applying" 30 | case terraShow: 31 | return "summarizing" 32 | case terraShowJSON: 33 | return "summarizing" 34 | } 35 | return "Unknown action" 36 | } 37 | 38 | func (t terraCmd) Cmd() string { 39 | switch t { 40 | case terraShowJSON: 41 | return string(terraShow) 42 | default: 43 | return string(t) 44 | } 45 | } 46 | 47 | const ( 48 | terraExe = "terraform" 49 | 50 | terraBuild terraCmd = "build" 51 | terraValidate terraCmd = "validate" 52 | terraInit terraCmd = "init" 53 | terraPlan terraCmd = "plan" 54 | terraApply terraCmd = "apply" 55 | // terraShow is used to run `terraform show` to provide human readle output 56 | terraShow terraCmd = "show" 57 | // terraShowJSON is used to run `terraform show -json` to provide machine 58 | // readable output 59 | terraShowJSON terraCmd = "showPlan" 60 | ) 61 | 62 | func buildCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 63 | var task TaskResult 64 | task.TerraCmd = terraBuild 65 | task.Error = builder.BuildTerrafile(tf, &task.Output) 66 | return &task 67 | } 68 | 69 | func validateCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 70 | var args []string 71 | args = append(args, tfCleanExtraArgs(opts.extraArgs)...) 72 | return runCmd(opts.out, tf, terraValidate, args) 73 | } 74 | 75 | func initCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 76 | var args []string 77 | if opts.initUpgrade { 78 | args = append(args, "-upgrade") 79 | } 80 | args = append(args, tfCleanExtraArgs(opts.extraArgs)...) 81 | return runCmd(opts.out, tf, terraInit, args) 82 | } 83 | 84 | func planCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 85 | plan := tf.ExecBlock.PlanBlock 86 | 87 | var args []string 88 | args = append(args, 89 | fmt.Sprintf("-lock=%v", plan.Lock), 90 | fmt.Sprintf("-input=%v", plan.Input), 91 | ) 92 | if !plan.SkipOut { 93 | args = append(args, 94 | "-out="+plan.Out, 95 | ) 96 | } 97 | args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) 98 | args = append(args, tfCleanExtraArgs(opts.extraArgs)...) 99 | return runCmd(opts.out, tf, terraPlan, args) 100 | } 101 | 102 | func showPlanCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 103 | plan := tf.ExecBlock.PlanBlock 104 | if plan.SkipOut { 105 | return &TaskResult{ 106 | TerraCmd: terraShowJSON, 107 | Skipped: true, 108 | } 109 | } 110 | var args []string 111 | args = append(args, "-json", plan.Out) 112 | return runCmd(opts.out, tf, terraShowJSON, args) 113 | } 114 | 115 | func showCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 116 | plan := tf.ExecBlock.PlanBlock 117 | if plan.SkipOut { 118 | return &TaskResult{ 119 | TerraCmd: terraShow, 120 | Skipped: true, 121 | } 122 | } 123 | var args []string 124 | args = append(args, plan.Out) 125 | return runCmd(opts.out, tf, terraShow, args) 126 | } 127 | 128 | func applyCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { 129 | plan := tf.ExecBlock.PlanBlock 130 | 131 | var args []string 132 | args = append(args, 133 | fmt.Sprintf("-lock=%v", plan.Lock), 134 | fmt.Sprintf("-input=%v", plan.Input), 135 | ) 136 | args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) 137 | args = append(args, tfCleanExtraArgs(opts.extraArgs)...) 138 | 139 | if !plan.SkipOut { 140 | args = append(args, plan.Out) 141 | } 142 | 143 | return runCmd(opts.out, tf, terraApply, args) 144 | } 145 | 146 | func runCmd(out io.Writer, tf *parser.Terrafile, tfCmd terraCmd, args []string) *TaskResult { 147 | task := TaskResult{ 148 | TerraCmd: tfCmd, 149 | } 150 | cmdArgs := append(tfArgs(tf), tfCmd.Cmd()) 151 | cmdArgs = append(cmdArgs, args...) 152 | task.ExecCmd = exec.Command(terraExe, cmdArgs...) 153 | 154 | // Create channel and start progress printer 155 | done := make(chan bool) 156 | go printProgress(out, tf.Dir, tfCmd, done) 157 | defer func() { done <- true }() 158 | 159 | pr, pw := io.Pipe() 160 | defer pw.Close() 161 | task.ExecCmd.Stdout = pw 162 | task.ExecCmd.Stderr = pw 163 | 164 | if err := task.ExecCmd.Start(); err != nil { 165 | task.Error = fmt.Errorf("starting command: %w", err) 166 | return &task 167 | } 168 | go func() { 169 | if _, err := io.Copy(&task.Output, pr); err != nil { 170 | log.Fatal(err) 171 | } 172 | }() 173 | 174 | runErr := task.ExecCmd.Wait() 175 | if runErr != nil { 176 | task.Error = fmt.Errorf("%s: running %s command", tf.Dir, tfCmd) 177 | } 178 | 179 | return &task 180 | } 181 | 182 | func tfArgs(tf *parser.Terrafile) []string { 183 | var args []string 184 | args = append(args, "-chdir="+tf.Dir) 185 | return args 186 | } 187 | 188 | // tfCleanExtraArgs returns the provided slice with any empty spaces removed. 189 | // Empty spaces create weird errors that are hard to debug 190 | func tfCleanExtraArgs(args []string) []string { 191 | var cleanArgs = make([]string, 0) 192 | for _, arg := range args { 193 | if arg != "" { 194 | cleanArgs = append(cleanArgs, arg) 195 | } 196 | } 197 | return cleanArgs 198 | } 199 | 200 | func printProgress(out io.Writer, path string, cmd terraCmd, done <-chan bool) { 201 | var ( 202 | interval = time.Second * 10 203 | ticker = time.NewTicker(interval) 204 | elapsed time.Duration 205 | ) 206 | defer ticker.Stop() 207 | // Print initial line 208 | fmt.Fprintf(out, "%s: %s...\n", path, cases.Title(language.English).String(cmd.Action())) 209 | for { 210 | select { 211 | case <-ticker.C: 212 | elapsed += interval 213 | fmt.Fprintf(out, "%s: Still %s... [%s elapsed]\n", path, cmd.Action(), elapsed) 214 | case <-done: 215 | return 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /runner/drift.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | 6 | tfjson "github.com/hashicorp/terraform-json" 7 | ) 8 | 9 | func driftFromPlan(plan *tfjson.Plan) *Drift { 10 | var drift Drift 11 | for _, change := range plan.ResourceChanges { 12 | for _, action := range change.Change.Actions { 13 | switch action { 14 | case tfjson.ActionCreate: 15 | drift.AddResources = append(drift.AddResources, change) 16 | case tfjson.ActionDelete: 17 | drift.DestroyResources = append(drift.DestroyResources, change) 18 | case tfjson.ActionUpdate: 19 | drift.ChangeResources = append(drift.ChangeResources, change) 20 | default: 21 | // We don't care about other actions for the summary 22 | } 23 | 24 | } 25 | } 26 | 27 | return &drift 28 | } 29 | 30 | type Drift struct { 31 | AddResources []*tfjson.ResourceChange 32 | ChangeResources []*tfjson.ResourceChange 33 | DestroyResources []*tfjson.ResourceChange 34 | } 35 | 36 | func (d *Drift) HasDrift() bool { 37 | if len(d.AddResources) == 0 && len(d.ChangeResources) == 0 && len(d.DestroyResources) == 0 { 38 | return false 39 | } 40 | 41 | return true 42 | } 43 | 44 | func (d *Drift) Diff() string { 45 | if !d.HasDrift() { 46 | return planNoChangesColor.Sprint("No changes.") 47 | } 48 | return fmt.Sprintf( 49 | "%s %s %s", 50 | planCreateColor.Sprintf("%d to add.", len(d.AddResources)), 51 | planUpdateColor.Sprintf("%d to change.", len(d.ChangeResources)), 52 | planDestroyColor.Sprintf("%d to destroy.", len(d.DestroyResources)), 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /runner/level.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "fmt" 4 | 5 | type OutputLevel string 6 | 7 | const ( 8 | OutputLevelAll OutputLevel = "all" 9 | OutputLevelDrift OutputLevel = "drift" 10 | ) 11 | 12 | func (o OutputLevel) ShowAll() bool { 13 | return o == OutputLevelAll 14 | } 15 | 16 | func (o OutputLevel) ShowDrift() bool { 17 | return o == OutputLevelAll || o == OutputLevelDrift 18 | } 19 | 20 | func (o OutputLevel) ShowError() bool { 21 | return true 22 | } 23 | 24 | func (o OutputLevel) Validate() (OutputLevel, error) { 25 | switch o { 26 | case OutputLevelAll, OutputLevelDrift: 27 | return o, nil 28 | default: 29 | return o, fmt.Errorf("unsupported output level: \"%s\"", o) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /runner/opts.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | func Jobs(jobs int) func(r *TerraRunOpts) { 9 | return func(r *TerraRunOpts) { 10 | r.jobs = jobs 11 | } 12 | } 13 | 14 | func RunBuild() func(r *TerraRunOpts) { 15 | return func(r *TerraRunOpts) { 16 | r.build = true 17 | } 18 | } 19 | 20 | func RunValidate() func(r *TerraRunOpts) { 21 | return func(r *TerraRunOpts) { 22 | r.validate = true 23 | } 24 | } 25 | 26 | func RunInit() func(r *TerraRunOpts) { 27 | return func(r *TerraRunOpts) { 28 | r.init = true 29 | } 30 | } 31 | 32 | func RunInitUpgrade() func(r *TerraRunOpts) { 33 | return func(r *TerraRunOpts) { 34 | r.init = true 35 | r.initUpgrade = true 36 | } 37 | } 38 | 39 | func RunPlan() func(r *TerraRunOpts) { 40 | return func(r *TerraRunOpts) { 41 | r.plan = true 42 | } 43 | } 44 | 45 | func RunShow() func(r *TerraRunOpts) { 46 | return func(r *TerraRunOpts) { 47 | r.show = true 48 | } 49 | } 50 | 51 | func RunShowPlan() func(r *TerraRunOpts) { 52 | return func(r *TerraRunOpts) { 53 | r.showPlan = true 54 | } 55 | } 56 | 57 | func RunApply() func(r *TerraRunOpts) { 58 | return func(r *TerraRunOpts) { 59 | r.apply = true 60 | } 61 | } 62 | 63 | func Output(out io.Writer) func(r *TerraRunOpts) { 64 | return func(r *TerraRunOpts) { 65 | r.out = out 66 | } 67 | } 68 | 69 | func ExtraArgs(extraArgs []string) func(r *TerraRunOpts) { 70 | return func(r *TerraRunOpts) { 71 | r.extraArgs = extraArgs 72 | } 73 | } 74 | 75 | func FromOpts(opts TerraRunOpts) func(r *TerraRunOpts) { 76 | return func(r *TerraRunOpts) { 77 | r.jobs = opts.jobs 78 | r.out = opts.out 79 | } 80 | } 81 | 82 | func NewOpts(opts ...func(r *TerraRunOpts)) TerraRunOpts { 83 | // Initialise TerraRunOpts with defaults 84 | runOpts := TerraRunOpts{ 85 | jobs: DefaultJobs, 86 | } 87 | for _, opt := range opts { 88 | opt(&runOpts) 89 | } 90 | 91 | // Set default output 92 | if runOpts.out == nil { 93 | runOpts.out = os.Stdout 94 | } 95 | return runOpts 96 | } 97 | 98 | // TerraRunOpts handles running Terraform over the root modules 99 | type TerraRunOpts struct { 100 | out io.Writer 101 | 102 | build bool 103 | validate bool 104 | init bool 105 | initUpgrade bool 106 | plan bool 107 | show bool 108 | showPlan bool 109 | apply bool 110 | 111 | // Max number of concurrent jobs allowed 112 | jobs int 113 | // Terraform command flags 114 | extraArgs []string 115 | } 116 | -------------------------------------------------------------------------------- /runner/rootmodule.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | 7 | "github.com/verifa/terraplate/parser" 8 | ) 9 | 10 | var ( 11 | ErrRunInProgress = errors.New("run is already in progress") 12 | ErrRunSkipped = errors.New("cannot run skipped module") 13 | ) 14 | 15 | // func newRootModule(tf *parser.Terrafile, opts TerraRunOpts) *RootModule { 16 | func newRootModule(tf *parser.Terrafile, opts TerraRunOpts) *RootModule { 17 | return &RootModule{ 18 | Terrafile: tf, 19 | Opts: opts, 20 | } 21 | } 22 | 23 | type RootModule struct { 24 | // Terrafile is the terrafile for which this run was executed 25 | Terrafile *parser.Terrafile 26 | Opts TerraRunOpts 27 | 28 | // Run stores the current run, if one has been scheduled 29 | Run *TerraRun 30 | mu sync.RWMutex 31 | } 32 | 33 | // ScheduleRun schedules a run on the RootModule by setting the state and 34 | // adding to the waitgroup. 35 | // If a run is already in progress an error is returned and the state is unchanged 36 | func (r *RootModule) ScheduleRun(runQueue chan *TerraRun) error { 37 | return r.ScheduleRunWithOpts(runQueue, r.Opts) 38 | } 39 | 40 | // ScheduleRun schedules a run on the RootModule by setting the state and 41 | // adding to the waitgroup. 42 | // If a run is already in progress an error is returned and the state is unchanged 43 | func (r *RootModule) ScheduleRunWithOpts(runQueue chan *TerraRun, opts TerraRunOpts) error { 44 | // Don't schedule runs for modules that should be skipped 45 | if r.Skip() { 46 | return ErrRunSkipped 47 | } 48 | r.mu.Lock() 49 | defer r.mu.Unlock() 50 | 51 | if r.Run != nil && r.Run.IsRunning() { 52 | return ErrRunInProgress 53 | } 54 | newRun := newRunForQueue(r.Terrafile, opts) 55 | r.Run = newRun 56 | runQueue <- newRun 57 | return nil 58 | } 59 | 60 | func (r *RootModule) Wait() { 61 | if r.Run != nil { 62 | r.Run.Wait() 63 | } 64 | } 65 | 66 | func (r *RootModule) Skip() bool { 67 | return r.Terrafile.ExecBlock.Skip 68 | } 69 | 70 | func (r *RootModule) IsRunning() bool { 71 | if r.Run == nil { 72 | return false 73 | } 74 | return r.Run.IsRunning() 75 | } 76 | 77 | func (r *RootModule) HasRun() bool { 78 | return r.Run != nil 79 | } 80 | -------------------------------------------------------------------------------- /runner/run.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | 8 | tfjson "github.com/hashicorp/terraform-json" 9 | "github.com/verifa/terraplate/parser" 10 | ) 11 | 12 | type state int 13 | 14 | const ( 15 | finishedState state = iota 16 | queueState 17 | runState 18 | ) 19 | 20 | func newRunForQueue(tf *parser.Terrafile, opts TerraRunOpts) *TerraRun { 21 | 22 | var r TerraRun 23 | r.Terrafile = tf 24 | r.Opts = opts 25 | r.state = queueState 26 | // Increment waitgroup so that we can wait on this run, even whilst it is 27 | // queueing, until it is finished 28 | r.wg.Add(1) 29 | return &r 30 | } 31 | 32 | type TerraRun struct { 33 | // Terrafile is the terrafile for which this run was executed 34 | Terrafile *parser.Terrafile 35 | Opts TerraRunOpts 36 | 37 | Tasks []*TaskResult 38 | Cancelled bool 39 | 40 | Plan *tfjson.Plan 41 | PlanText []byte 42 | 43 | drift *Drift 44 | 45 | wg sync.WaitGroup 46 | state state 47 | } 48 | 49 | // Run performs a blocking run for this TerraRun i.e. invoking Terraform 50 | func (r *TerraRun) Run() { 51 | r.Start() 52 | r.Wait() 53 | } 54 | 55 | // Start performs a non-blocking run for this TerraRun i.e. invoking Terraform 56 | func (r *TerraRun) Start() { 57 | r.startRun() 58 | defer r.endRun() 59 | 60 | tf := r.Terrafile 61 | 62 | if r.Opts.build { 63 | taskResult := buildCmd(r.Opts, tf) 64 | r.Tasks = append(r.Tasks, taskResult) 65 | if taskResult.HasError() { 66 | return 67 | } 68 | } 69 | if r.Opts.init { 70 | taskResult := initCmd(r.Opts, tf) 71 | r.Tasks = append(r.Tasks, taskResult) 72 | if taskResult.HasError() { 73 | return 74 | } 75 | } 76 | if r.Opts.validate { 77 | taskResult := validateCmd(r.Opts, tf) 78 | r.Tasks = append(r.Tasks, taskResult) 79 | if taskResult.HasError() { 80 | return 81 | } 82 | } 83 | if r.Opts.plan { 84 | taskResult := planCmd(r.Opts, tf) 85 | r.Tasks = append(r.Tasks, taskResult) 86 | if taskResult.HasError() { 87 | return 88 | } 89 | } 90 | if r.Opts.show { 91 | taskResult := showCmd(r.Opts, tf) 92 | r.Tasks = append(r.Tasks, taskResult) 93 | if taskResult.HasError() { 94 | return 95 | } 96 | } 97 | if r.Opts.showPlan { 98 | taskResult := showPlanCmd(r.Opts, tf) 99 | r.ProcessPlan(taskResult) 100 | r.Tasks = append(r.Tasks, taskResult) 101 | if taskResult.HasError() { 102 | return 103 | } 104 | } 105 | if r.Opts.apply { 106 | taskResult := applyCmd(r.Opts, tf) 107 | r.Tasks = append(r.Tasks, taskResult) 108 | if taskResult.HasError() { 109 | return 110 | } 111 | } 112 | } 113 | 114 | // Wait blocks and waits for the run to be finished 115 | func (r *TerraRun) Wait() { 116 | r.wg.Wait() 117 | } 118 | 119 | func (r *TerraRun) startRun() { 120 | r.state = runState 121 | } 122 | 123 | func (r *TerraRun) endRun() { 124 | defer r.wg.Done() 125 | r.state = finishedState 126 | } 127 | 128 | // Log returns the log output of a TerraRun as a string. 129 | // Tasks are logged either if the fullLog parameter is true or if the 130 | // task is considered relevant 131 | func (r *TerraRun) Log(fullLog bool) string { 132 | var log strings.Builder 133 | log.WriteString(boldColor.Sprintf("Run for %s\n\n", r.Terrafile.Dir)) 134 | 135 | for _, task := range r.Tasks { 136 | if fullLog || r.isTaskRelevant(task, OutputLevelAll) { 137 | log.WriteString(task.Log()) 138 | } 139 | } 140 | return log.String() 141 | } 142 | 143 | // Summary returns a string summary to show after a plan 144 | func (r *TerraRun) Summary() string { 145 | switch { 146 | case r.HasError(): 147 | return errorColor.Sprint("Error occurred") 148 | case r.Cancelled: 149 | return runCancelled.Sprint("Cancelled") 150 | case r.IsApplied(): 151 | return boldColor.Sprint("Applied") 152 | case r.IsPlanned(): 153 | if !r.HasPlan() { 154 | return planNotAvailable.Sprint("Plan not available") 155 | } 156 | return r.Drift().Diff() 157 | case r.IsInitd(): 158 | return boldColor.Sprint("Initialized") 159 | case r.IsBuilt(): 160 | return boldColor.Sprint("Built") 161 | default: 162 | return "Unknown status" 163 | } 164 | } 165 | 166 | // Drift returns the drift from a Terraform Run. 167 | // To avoid re-calculating it multiple times, store the drift in a local 168 | // member and only calculate it once 169 | func (r *TerraRun) Drift() *Drift { 170 | if r == nil || !r.HasPlan() { 171 | // Return an empty drift which means no drift (though user should check 172 | // if plan was available as well) 173 | return &Drift{} 174 | } 175 | if r.drift != nil { 176 | return r.drift 177 | } 178 | r.drift = driftFromPlan(r.Plan) 179 | return r.drift 180 | } 181 | 182 | func (r *TerraRun) IsApplied() bool { 183 | if r == nil { 184 | return false 185 | } 186 | if r.state != finishedState { 187 | return false 188 | } 189 | for _, task := range r.Tasks { 190 | if task.TerraCmd == terraApply { 191 | return true 192 | } 193 | } 194 | return false 195 | } 196 | 197 | func (r *TerraRun) IsPlanned() bool { 198 | if r == nil { 199 | return false 200 | } 201 | if r.state != finishedState { 202 | return false 203 | } 204 | for _, task := range r.Tasks { 205 | switch task.TerraCmd { 206 | case terraPlan, terraShowJSON: 207 | return true 208 | } 209 | } 210 | return false 211 | } 212 | 213 | func (r *TerraRun) IsInitd() bool { 214 | if r == nil { 215 | return false 216 | } 217 | if r.state != finishedState { 218 | return false 219 | } 220 | for _, task := range r.Tasks { 221 | if task.TerraCmd == terraInit { 222 | return true 223 | } 224 | } 225 | return false 226 | } 227 | 228 | func (r *TerraRun) IsBuilt() bool { 229 | if r == nil { 230 | return false 231 | } 232 | if r.state != finishedState { 233 | return false 234 | } 235 | for _, task := range r.Tasks { 236 | if task.TerraCmd == terraBuild { 237 | return true 238 | } 239 | } 240 | return false 241 | } 242 | 243 | func (r *TerraRun) IsRunning() bool { 244 | if r == nil { 245 | return false 246 | } 247 | return r.state == queueState || r.state == runState 248 | } 249 | 250 | func (r *TerraRun) HasError() bool { 251 | if r == nil { 252 | return false 253 | } 254 | for _, task := range r.Tasks { 255 | if task.HasError() { 256 | return true 257 | } 258 | } 259 | return false 260 | } 261 | 262 | func (r *TerraRun) Errors() []error { 263 | var errors []error 264 | for _, task := range r.Tasks { 265 | if task.HasError() { 266 | errors = append(errors, task.Error) 267 | } 268 | } 269 | return errors 270 | } 271 | 272 | func (r *TerraRun) HasRelevantTasks(level OutputLevel) bool { 273 | for _, task := range r.Tasks { 274 | if r.isTaskRelevant(task, level) { 275 | return true 276 | } 277 | } 278 | return false 279 | } 280 | 281 | func (r *TerraRun) HasPlan() bool { 282 | if r == nil { 283 | return false 284 | } 285 | return r.Plan != nil 286 | } 287 | 288 | // ProcessPlanText takes a TaskResult from a terraform show (with -json option) 289 | // which makes for a compact human-readable output which we can show instead of 290 | // the raw output from terraform plan 291 | func (r *TerraRun) ProcessPlan(task *TaskResult) error { 292 | // Make sure we received a `terraform show` task result 293 | if task.TerraCmd != terraShowJSON { 294 | return fmt.Errorf("terraform show command required for processing plan: received %s", task.TerraCmd) 295 | } 296 | // Cannot process a plan if the `terraform show` command error'd 297 | if task.HasError() { 298 | return nil 299 | } 300 | var tfPlan tfjson.Plan 301 | if err := tfPlan.UnmarshalJSON(task.Output.Bytes()); err != nil { 302 | return fmt.Errorf("unmarshalling terraform show plan output: %w", err) 303 | } 304 | 305 | r.Plan = &tfPlan 306 | 307 | return nil 308 | } 309 | 310 | func (r *TerraRun) isTaskRelevant(task *TaskResult, level OutputLevel) bool { 311 | hasDrift := r.Drift().HasDrift() 312 | // Always show errors 313 | if task.HasError() { 314 | return true 315 | } 316 | // Skipped tasks are not relevant 317 | if task.Skipped { 318 | return false 319 | } 320 | switch task.TerraCmd { 321 | case terraPlan: 322 | return level.ShowAll() || (level.ShowDrift() && hasDrift) 323 | case terraShow: 324 | return level.ShowAll() || (level.ShowDrift() && hasDrift) 325 | case terraApply: 326 | return true 327 | default: 328 | // Skip other command outputs 329 | return false 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "sync" 10 | "syscall" 11 | 12 | "github.com/hashicorp/go-multierror" 13 | "github.com/verifa/terraplate/parser" 14 | ) 15 | 16 | const ( 17 | DefaultJobs = 4 18 | maxRunQueue = 200 19 | ) 20 | 21 | func Run(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { 22 | runner := New(config, opts...) 23 | runner.RunAll() 24 | return runner 25 | } 26 | 27 | func New(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { 28 | runOpts := NewOpts(opts...) 29 | 30 | // Create the runQueue which is used by the workers to schedule runs 31 | runQueue := make(chan *TerraRun, maxRunQueue) 32 | 33 | runner := Runner{ 34 | ctx: listenTerminateSignals(runQueue), 35 | runQueue: runQueue, 36 | config: config, 37 | Opts: runOpts, 38 | } 39 | // Initialize the workers in separate go routines 40 | for workerID := 0; workerID < runOpts.jobs; workerID++ { 41 | go runner.startWorker(workerID) 42 | } 43 | 44 | // Initialize result 45 | var ( 46 | // Get only root module Terrafiles 47 | tfs = config.RootModules() 48 | modules = make([]*RootModule, len(tfs)) 49 | ) 50 | for index, tf := range tfs { 51 | modules[index] = newRootModule(tf, runOpts) 52 | } 53 | runner.Modules = modules 54 | return &runner 55 | } 56 | 57 | type Runner struct { 58 | ctx context.Context 59 | // runQueue is a channel for managing the run queue 60 | runQueue chan *TerraRun 61 | wg sync.WaitGroup 62 | 63 | config *parser.TerraConfig 64 | 65 | Opts TerraRunOpts 66 | Modules []*RootModule 67 | } 68 | 69 | func (r *Runner) WorkingDirectory() string { 70 | return r.config.WorkingDirectory 71 | } 72 | 73 | func (r *Runner) RunAll() { 74 | r.Run(r.Modules) 75 | } 76 | 77 | func (r *Runner) Run(modules []*RootModule) { 78 | r.Start(modules) 79 | r.Wait() 80 | } 81 | 82 | func (r *Runner) Start(modules []*RootModule) { 83 | r.StartWithOpts(modules, r.Opts) 84 | } 85 | 86 | func (r *Runner) StartWithOpts(modules []*RootModule, opts TerraRunOpts) { 87 | for _, mod := range modules { 88 | // Check that the run is not in progress 89 | if runErr := mod.ScheduleRunWithOpts(r.runQueue, opts); runErr != nil { 90 | continue 91 | } 92 | // If run was scheduled, add to waitgroup 93 | r.wg.Add(1) 94 | } 95 | } 96 | 97 | func (r *Runner) Wait() { 98 | r.wg.Wait() 99 | } 100 | 101 | // Runs returns the list of latest runs for the root modules 102 | func (r *Runner) Runs() []*TerraRun { 103 | var runs = make([]*TerraRun, 0) 104 | for _, mod := range r.Modules { 105 | if mod.Run != nil { 106 | runs = append(runs, mod.Run) 107 | } 108 | } 109 | return runs 110 | } 111 | 112 | // Log returns a string of the runs and tasks to print to the console 113 | func (r *Runner) Log(level OutputLevel) string { 114 | var ( 115 | summary strings.Builder 116 | hasRelevantRuns bool 117 | ) 118 | summary.WriteString(textSeparator) 119 | for _, run := range r.Runs() { 120 | // Skip runs that have nothing relevant to show 121 | if !run.HasRelevantTasks(level) { 122 | continue 123 | } 124 | hasRelevantRuns = true 125 | 126 | summary.WriteString(run.Log(false)) 127 | } 128 | // If there were no runs to output, return an empty string to avoid printing 129 | // separators and empty space 130 | if !hasRelevantRuns { 131 | return "" 132 | } 133 | summary.WriteString(textSeparator) 134 | return summary.String() 135 | } 136 | 137 | // Summary returns a string summary to show after a plan 138 | func (r *Runner) Summary(level OutputLevel) string { 139 | var ( 140 | summary strings.Builder 141 | hasRelevantRuns bool 142 | ) 143 | summary.WriteString(boldColor.Sprint("\nTerraplate Summary\n\n")) 144 | for _, run := range r.Runs() { 145 | showSummary := level.ShowAll() || (run.Drift().HasDrift() && level.ShowDrift()) || run.HasError() 146 | 147 | if showSummary { 148 | hasRelevantRuns = true 149 | summary.WriteString(fmt.Sprintf("%s: %s\n", run.Terrafile.Dir, run.Summary())) 150 | } 151 | } 152 | if !hasRelevantRuns { 153 | summary.WriteString("Everything up to date: no drift and no errors\n") 154 | } 155 | return summary.String() 156 | } 157 | 158 | func (r *Runner) RunsWithDrift() []*TerraRun { 159 | var runs []*TerraRun 160 | for _, run := range r.Runs() { 161 | if run.Drift().HasDrift() { 162 | runs = append(runs, run) 163 | } 164 | } 165 | return runs 166 | } 167 | 168 | func (r *Runner) RunsWithError() []*TerraRun { 169 | var runs []*TerraRun 170 | for _, run := range r.Runs() { 171 | if run.HasError() { 172 | runs = append(runs, run) 173 | } 174 | } 175 | return runs 176 | } 177 | 178 | // HasDrift returns true if any drift was detected in any of the runs 179 | func (r *Runner) HasDrift() bool { 180 | for _, run := range r.Runs() { 181 | if drift := run.Drift(); drift != nil { 182 | // If at least one of the runs has drifted, then our result has drift 183 | if drift.HasDrift() { 184 | return true 185 | } 186 | } 187 | } 188 | return false 189 | } 190 | 191 | func (r *Runner) HasError() bool { 192 | for _, run := range r.Runs() { 193 | if run.HasError() { 194 | return true 195 | } 196 | } 197 | return false 198 | } 199 | 200 | // Errors returns a multierror with any errors found in any tasks within the runs 201 | func (r *Runner) Errors() error { 202 | var err error 203 | for _, run := range r.Runs() { 204 | if run.HasError() { 205 | err = multierror.Append(err, run.Errors()...) 206 | } 207 | } 208 | return err 209 | } 210 | 211 | // listenTerminateSignals returns a context that will be cancelled if an interrupt 212 | // or termination signal is received. The context can be used to prevent further 213 | // runs from being scheduled 214 | func listenTerminateSignals(runQueue chan *TerraRun) context.Context { 215 | ctx, cancel := context.WithCancel(context.Background()) 216 | signals := make(chan os.Signal, 1) 217 | signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) 218 | go func() { 219 | for { 220 | <-signals 221 | fmt.Println("") 222 | fmt.Println("Terraplate: Interrupt received.") 223 | fmt.Println("Terraplate: Sending interrupt to all Terraform processes and cancelling any queued runs.") 224 | fmt.Println("") 225 | // Cancel the context and stop any more runs from being executed 226 | cancel() 227 | close(runQueue) 228 | } 229 | }() 230 | return ctx 231 | } 232 | -------------------------------------------------------------------------------- /runner/styles.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "github.com/fatih/color" 4 | 5 | var ( 6 | boldColor = color.New(color.Bold) 7 | errorColor = color.New(color.FgRed, color.Bold) 8 | runCancelled = color.New(color.FgRed, color.Bold) 9 | planNotAvailable = color.New(color.FgMagenta, color.Bold) 10 | planNoChangesColor = color.New(color.FgGreen, color.Bold) 11 | planCreateColor = color.New(color.FgGreen, color.Bold) 12 | planDestroyColor = color.New(color.FgRed, color.Bold) 13 | planUpdateColor = color.New(color.FgYellow, color.Bold) 14 | ) 15 | 16 | var ( 17 | textSeparator = boldColor.Sprint("\n─────────────────────────────────────────────────────────────────────────────\n\n") 18 | ) 19 | -------------------------------------------------------------------------------- /runner/task.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | 10 | "golang.org/x/text/cases" 11 | "golang.org/x/text/language" 12 | ) 13 | 14 | type TaskResult struct { 15 | ExecCmd *exec.Cmd 16 | TerraCmd terraCmd 17 | 18 | Output bytes.Buffer 19 | Error error 20 | Skipped bool 21 | } 22 | 23 | func (t *TaskResult) HasError() bool { 24 | return t.Error != nil 25 | } 26 | 27 | func (t *TaskResult) Log() string { 28 | var ( 29 | summary strings.Builder 30 | tmp bytes.Buffer 31 | caser = cases.Title(language.English) 32 | ) 33 | 34 | // Make a copy of the output bytes as the scanner below will Read the io 35 | // and therefore "empty" it, and we don't want to empty the output bytes 36 | if _, err := tmp.Write(t.Output.Bytes()); err != nil { 37 | return "Error: writing task output to temporary buffer" 38 | } 39 | 40 | switch t.TerraCmd { 41 | case terraBuild: 42 | summary.WriteString("Build output:\n\n") 43 | default: 44 | summary.WriteString(fmt.Sprintf("%s output: %s\n\n", caser.String(string(t.TerraCmd)), t.ExecCmd.String())) 45 | } 46 | if t.HasError() { 47 | summary.WriteString(fmt.Sprintf("Error: %s\n\n", t.Error.Error())) 48 | } 49 | scanner := bufio.NewScanner(&tmp) 50 | for scanner.Scan() { 51 | summary.WriteString(fmt.Sprintf(" %s\n", scanner.Text())) 52 | } 53 | summary.WriteString("\n\n") 54 | 55 | return summary.String() 56 | } 57 | -------------------------------------------------------------------------------- /runner/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for i in {1..3}; do 4 | echo "step " $i 5 | sleep 1s 6 | echo >&2 "stderr!!" 7 | done 8 | -------------------------------------------------------------------------------- /runner/testData/empty.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/verifa/terraplate/d8ef07863218958eb87b93e7c2a00e66ba5ba8ab/runner/testData/empty.tf -------------------------------------------------------------------------------- /runner/worker.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | func (r *Runner) startWorker(workerID int) { 9 | for run := range r.runQueue { 10 | // Check if context has been cancelled 11 | if errors.Is(r.ctx.Err(), context.Canceled) { 12 | run.Cancelled = true 13 | } else { 14 | run.Run() 15 | } 16 | r.wg.Done() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tui/entryui/commands.go: -------------------------------------------------------------------------------- 1 | package entryui 2 | 3 | import ( 4 | "time" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/verifa/terraplate/runner" 8 | ) 9 | 10 | type BackMsg struct{} 11 | 12 | func backToListMsg() tea.Cmd { 13 | return func() tea.Msg { 14 | return BackMsg{} 15 | } 16 | } 17 | 18 | type runFinished struct{} 19 | 20 | type runInProgress struct{} 21 | 22 | func RunInProgressCmd(module *runner.RootModule) tea.Cmd { 23 | 24 | var cmds = make([]tea.Cmd, 0) 25 | if module.IsRunning() { 26 | cmds = append(cmds, tickRunInProgress(module)) 27 | } 28 | 29 | cmds = append(cmds, func() tea.Msg { 30 | module.Wait() 31 | return runFinished{} 32 | }) 33 | 34 | return tea.Batch(cmds...) 35 | } 36 | 37 | func tickRunInProgress(module *runner.RootModule) tea.Cmd { 38 | return func() tea.Msg { 39 | time.Sleep(500 * time.Millisecond) 40 | return runInProgress{} 41 | } 42 | } 43 | 44 | func runModuleCmd(r *runner.Runner, module *runner.RootModule) tea.Cmd { 45 | r.Start([]*runner.RootModule{module}) 46 | return RunInProgressCmd(module) 47 | } 48 | -------------------------------------------------------------------------------- /tui/entryui/keys.go: -------------------------------------------------------------------------------- 1 | package entryui 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var keys = keymap{ 6 | prevSection: key.NewBinding( 7 | key.WithKeys("left", "h"), 8 | key.WithHelp("/h", "previous section"), 9 | ), 10 | nextSection: key.NewBinding( 11 | key.WithKeys("right", "l"), 12 | key.WithHelp("/l", "next section"), 13 | ), 14 | back: key.NewBinding( 15 | key.WithKeys("esc"), 16 | key.WithHelp("esc", "back to list"), 17 | ), 18 | run: key.NewBinding( 19 | key.WithKeys(" "), 20 | key.WithHelp("␣", "run selected"), 21 | ), 22 | } 23 | 24 | type keymap struct { 25 | back key.Binding 26 | nextSection key.Binding 27 | prevSection key.Binding 28 | run key.Binding 29 | } 30 | 31 | func (k keymap) ShortHelp() []key.Binding { 32 | return []key.Binding{ 33 | k.back, k.nextSection, k.prevSection, k.run, 34 | } 35 | } 36 | 37 | func (k keymap) FullHelp() [][]key.Binding { 38 | return [][]key.Binding{ 39 | { 40 | k.back, k.nextSection, k.prevSection, k.run, 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tui/entryui/model.go: -------------------------------------------------------------------------------- 1 | package entryui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/verifa/terraplate/runner" 13 | "golang.org/x/text/cases" 14 | "golang.org/x/text/language" 15 | ) 16 | 17 | var _ tea.Model = (*Model)(nil) 18 | 19 | func New(runner *runner.Runner, module *runner.RootModule, windowSize tea.WindowSizeMsg) tea.Model { 20 | 21 | m := Model{ 22 | runner: runner, 23 | activeTask: 0, 24 | help: help.New(), 25 | module: module, 26 | windowSize: windowSize, 27 | } 28 | 29 | m.viewport = viewport.New(0, 0) 30 | m.syncViewportSize() 31 | m.viewport.MouseWheelEnabled = true 32 | m.viewport.SetContent(m.viewportContent()) 33 | 34 | return m 35 | } 36 | 37 | type Model struct { 38 | runner *runner.Runner 39 | activeTask int 40 | viewport viewport.Model 41 | help help.Model 42 | module *runner.RootModule 43 | windowSize tea.WindowSizeMsg 44 | } 45 | 46 | func (m Model) Init() tea.Cmd { 47 | return nil 48 | } 49 | 50 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 51 | var cmd tea.Cmd 52 | switch msg := msg.(type) { 53 | case tea.WindowSizeMsg: 54 | m.windowSize = msg 55 | m.syncViewportSize() 56 | case runInProgress: 57 | if m.module.IsRunning() { 58 | cmd = tickRunInProgress(m.module) 59 | m.viewport.SetContent(m.viewportContent()) 60 | m.viewport.GotoBottom() 61 | m.syncViewportSize() 62 | } 63 | case runFinished: 64 | m.viewport.SetContent(m.viewportContent()) 65 | m.viewport.GotoTop() 66 | m.syncViewportSize() 67 | case tea.KeyMsg: 68 | switch { 69 | case msg.String() == "q": 70 | cmd = backToListMsg() 71 | case key.Matches(msg, keys.back): 72 | cmd = backToListMsg() 73 | case key.Matches(msg, keys.nextSection): 74 | numTasks := m.numTasks() 75 | if numTasks > 0 { 76 | // We will use the index of this, so it has to be the length minus 1 77 | numTasks-- 78 | } 79 | m.activeTask = min(m.activeTask+1, numTasks) 80 | m.viewport.SetContent(m.viewportContent()) 81 | m.viewport.GotoTop() 82 | case key.Matches(msg, keys.prevSection): 83 | m.activeTask = max(m.activeTask-1, 0) 84 | m.viewport.SetContent(m.viewportContent()) 85 | m.viewport.GotoTop() 86 | case key.Matches(msg, keys.run): 87 | cmd = runModuleCmd(m.runner, m.module) 88 | m.viewport.SetContent(m.viewportContent()) 89 | m.viewport.GotoTop() 90 | default: 91 | m.viewport, cmd = m.viewport.Update(msg) 92 | } 93 | 94 | case tea.MouseMsg: 95 | m.viewport, cmd = m.viewport.Update(msg) 96 | } 97 | return m, cmd 98 | } 99 | 100 | func (m Model) View() string { 101 | return lipgloss.JoinVertical( 102 | lipgloss.Left, 103 | m.renderHeader(), 104 | m.viewport.View(), 105 | m.renderFooter(), 106 | ) 107 | } 108 | 109 | func (m Model) viewportContent() string { 110 | style := lipgloss.NewStyle().Width(m.viewport.Width) 111 | run := m.module.Run 112 | switch { 113 | case run == nil: 114 | return style.Render("Not run.") 115 | case run.IsRunning(): 116 | return style.Render(run.Log(true)) 117 | case m.numTasks() == 0: 118 | return style.Render("No tasks to show") 119 | } 120 | return style.Render(run.Tasks[m.activeTask].Output.String()) 121 | } 122 | 123 | func (m Model) renderHeader() string { 124 | var ( 125 | run = m.module.Run 126 | doc strings.Builder 127 | tabs []string 128 | caser = cases.Title(language.English) 129 | ) 130 | // Don't render the tabs unless the run is finished 131 | if !m.module.IsRunning() { 132 | tabs = make([]string, m.numTasks()) 133 | for i := 0; i < m.numTasks(); i++ { 134 | task := run.Tasks[i] 135 | if i == m.activeTask { 136 | tabs[i] = activeTab.Render(caser.String(string(task.TerraCmd))) 137 | continue 138 | } 139 | tabs[i] = tab.Render(caser.String(string(task.TerraCmd))) 140 | 141 | } 142 | } 143 | row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) 144 | gap := tabGap.Render(strings.Repeat(" ", max(0, m.windowSize.Width-lipgloss.Width(row)))) 145 | doc.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap)) 146 | return doc.String() + "\n" 147 | } 148 | 149 | func (m Model) renderFooter() string { 150 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 151 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 152 | status := lipgloss.JoinHorizontal( 153 | lipgloss.Center, 154 | line, 155 | info, 156 | ) 157 | 158 | return lipgloss.JoinVertical( 159 | lipgloss.Left, 160 | status, 161 | m.renderStatus(), 162 | m.help.View(keys), 163 | ) 164 | } 165 | 166 | func (m Model) renderStatus() string { 167 | return "Module: " + m.module.Terrafile.Dir 168 | } 169 | 170 | func (m *Model) syncViewportSize() { 171 | headerHeight := lipgloss.Height(m.renderHeader()) 172 | footerHeight := lipgloss.Height(m.renderFooter()) 173 | 174 | m.viewport.Width = m.windowSize.Width 175 | m.viewport.Height = max(0, m.windowSize.Height-headerHeight-footerHeight) 176 | } 177 | 178 | func (m Model) numTasks() int { 179 | if m.module.Run == nil { 180 | return 0 181 | } 182 | return len(m.module.Run.Tasks) 183 | } 184 | 185 | func max(a, b int) int { 186 | if a > b { 187 | return a 188 | } 189 | return b 190 | } 191 | 192 | func min(a, b int) int { 193 | if a > b { 194 | return b 195 | } 196 | return a 197 | } 198 | -------------------------------------------------------------------------------- /tui/entryui/styles.go: -------------------------------------------------------------------------------- 1 | package entryui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 7 | 8 | tabBorder = lipgloss.Border{ 9 | Top: "─", 10 | Bottom: "─", 11 | Left: "│", 12 | Right: "│", 13 | TopLeft: "╭", 14 | TopRight: "╮", 15 | BottomLeft: "┴", 16 | BottomRight: "┴", 17 | } 18 | activeTabBorder = lipgloss.Border{ 19 | Top: "─", 20 | Bottom: " ", 21 | Left: "│", 22 | Right: "│", 23 | TopLeft: "╭", 24 | TopRight: "╮", 25 | BottomLeft: "┘", 26 | BottomRight: "└", 27 | } 28 | 29 | tab = lipgloss.NewStyle(). 30 | Border(tabBorder, true). 31 | BorderForeground(highlight). 32 | Padding(0, 1) 33 | 34 | activeTab = tab.Copy().Border(activeTabBorder, true) 35 | 36 | tabGap = tab.Copy(). 37 | BorderTop(false). 38 | BorderLeft(false). 39 | BorderRight(false) 40 | 41 | viewTitleStyle = func() lipgloss.Style { 42 | b := lipgloss.RoundedBorder() 43 | b.Right = "├" 44 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 45 | }() 46 | 47 | infoStyle = func() lipgloss.Style { 48 | b := lipgloss.RoundedBorder() 49 | b.Left = "┤" 50 | return viewTitleStyle.Copy().BorderStyle(b) 51 | }() 52 | ) 53 | -------------------------------------------------------------------------------- /tui/model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/verifa/terraplate/runner" 6 | "github.com/verifa/terraplate/tui/entryui" 7 | "github.com/verifa/terraplate/tui/modulesui" 8 | ) 9 | 10 | var _ tea.Model = (*MainModel)(nil) 11 | 12 | type MainModel struct { 13 | runner *runner.Runner 14 | 15 | modules tea.Model 16 | entry tea.Model 17 | 18 | state state 19 | windowSize tea.WindowSizeMsg 20 | } 21 | 22 | func New(runner *runner.Runner) MainModel { 23 | return MainModel{ 24 | runner: runner, 25 | state: modulesView, 26 | modules: modulesui.New(runner), 27 | } 28 | } 29 | 30 | func (m MainModel) Init() tea.Cmd { 31 | return nil 32 | } 33 | 34 | func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 35 | var cmd tea.Cmd 36 | var cmds []tea.Cmd 37 | switch msg := msg.(type) { 38 | case tea.WindowSizeMsg: 39 | m.windowSize = msg // pass this along to the entry view so it uses the full window size when it's initialized 40 | case tea.KeyMsg: 41 | switch msg.String() { 42 | case "ctrl+c": 43 | return m, tea.Quit 44 | } 45 | case entryui.BackMsg: 46 | m.state = modulesView 47 | case modulesui.SelectMsg: 48 | m.state = entryView 49 | m.entry = entryui.New(m.runner, msg.Module, m.windowSize) 50 | if msg.Module.IsRunning() { 51 | cmds = append(cmds, entryui.RunInProgressCmd(msg.Module)) 52 | } 53 | } 54 | 55 | switch m.state { 56 | case modulesView: 57 | m.modules, cmd = m.modules.Update(msg) 58 | case entryView: 59 | m.entry, cmd = m.entry.Update(msg) 60 | } 61 | cmds = append(cmds, cmd) 62 | return m, tea.Batch(cmds...) 63 | } 64 | 65 | func (m MainModel) View() string { 66 | switch m.state { 67 | case modulesView: 68 | return m.modules.View() 69 | case entryView: 70 | return m.entry.View() 71 | } 72 | return "Error: no view selected" 73 | } 74 | -------------------------------------------------------------------------------- /tui/modulesui/columns.go: -------------------------------------------------------------------------------- 1 | package modulesui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | type tableColumnsMap struct { 6 | status column 7 | rootModule column 8 | summary column 9 | } 10 | 11 | func (c tableColumnsMap) columns() []column { 12 | return []column{ 13 | c.status, c.rootModule, c.summary, 14 | } 15 | } 16 | 17 | var ( 18 | tableColumns = tableColumnsMap{ 19 | status: column{ 20 | header: "Status", 21 | width: 10, 22 | grow: false, 23 | }, 24 | rootModule: column{ 25 | header: "Root Module", 26 | width: 60, 27 | grow: true, 28 | }, 29 | summary: column{ 30 | header: "Summary", 31 | width: 60, 32 | grow: true, 33 | }, 34 | } 35 | ) 36 | 37 | type column struct { 38 | header string 39 | width int 40 | grow bool 41 | } 42 | 43 | func (c column) render(style lipgloss.Style, text string) string { 44 | return style.Width(c.width).Render(text) 45 | } 46 | -------------------------------------------------------------------------------- /tui/modulesui/commands.go: -------------------------------------------------------------------------------- 1 | package modulesui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/verifa/terraplate/runner" 10 | ) 11 | 12 | type SelectMsg struct { 13 | Module *runner.RootModule 14 | } 15 | 16 | func (m Model) selectModuleCmd() tea.Cmd { 17 | return func() tea.Msg { 18 | return SelectMsg{Module: m.activeModule()} 19 | } 20 | } 21 | 22 | type updateModuleMsg struct { 23 | Module *runner.RootModule 24 | } 25 | 26 | func (m Model) runActiveModuleCmd() tea.Cmd { 27 | modules := []*runner.RootModule{ 28 | m.activeModule(), 29 | } 30 | return m.runModulesWithOptsCmd(modules, m.runner.Opts) 31 | } 32 | 33 | func (m Model) runModulesWithOptsCmd(modules []*runner.RootModule, opts runner.TerraRunOpts) tea.Cmd { 34 | m.runner.StartWithOpts(modules, opts) 35 | 36 | var cmds = make([]tea.Cmd, len(modules)) 37 | for _, mod := range modules { 38 | mod := mod 39 | cmds = append(cmds, func() tea.Msg { 40 | mod.Wait() 41 | return updateModuleMsg{Module: mod} 42 | }) 43 | } 44 | 45 | return tea.Batch(cmds...) 46 | } 47 | 48 | func (m Model) inputCmd() (tea.Cmd, error) { 49 | opts, runAll, cmdErr := m.parseInputCmd(m.input.Value()) 50 | if cmdErr != nil { 51 | return nil, cmdErr 52 | } 53 | var modules []*runner.RootModule 54 | if runAll { 55 | modules = m.visibleModules() 56 | } else { 57 | modules = append(modules, m.activeModule()) 58 | } 59 | return m.runModulesWithOptsCmd(modules, opts), nil 60 | } 61 | 62 | func (m Model) parseInputCmd(cmd string) (runner.TerraRunOpts, bool, error) { 63 | var ( 64 | opts []func(*runner.TerraRunOpts) 65 | runAll bool 66 | ) 67 | if len(cmd) == 0 { 68 | return runner.TerraRunOpts{}, runAll, errors.New("command is empty") 69 | } 70 | // Inherit from existing run, e.g. number of jobs and output 71 | opts = append(opts, runner.FromOpts(m.runner.Opts)) 72 | for _, c := range cmd { 73 | switch c { 74 | case 'b': 75 | opts = append(opts, runner.RunBuild()) 76 | case 'i': 77 | opts = append(opts, runner.RunBuild(), runner.RunInit()) 78 | case 'u': 79 | opts = append(opts, runner.RunBuild(), runner.RunInitUpgrade()) 80 | case 'p': 81 | // If we are planning, also run the terraform show command to get 82 | // the JSON output of the plan 83 | opts = append(opts, runner.RunBuild(), runner.RunPlan(), runner.RunShowPlan()) 84 | case 'a': 85 | opts = append(opts, runner.RunApply()) 86 | case 'A': 87 | runAll = true 88 | default: 89 | return runner.TerraRunOpts{}, runAll, fmt.Errorf("unknown command: %c", c) 90 | } 91 | } 92 | runOpts := runner.NewOpts(opts...) 93 | return runOpts, runAll, nil 94 | } 95 | 96 | type statusTimeoutMsg struct { 97 | // uuid uuid.UUID 98 | err error 99 | } 100 | 101 | func statusTimeoutCmd(err error) tea.Cmd { 102 | // uuid := uuid.New() 103 | timer := time.NewTimer(2 * time.Second) 104 | return func() tea.Msg { 105 | <-timer.C 106 | return statusTimeoutMsg{err: err} 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tui/modulesui/item.go: -------------------------------------------------------------------------------- 1 | package modulesui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/verifa/terraplate/runner" 8 | ) 9 | 10 | type listItem struct { 11 | module *runner.RootModule 12 | } 13 | 14 | func (i listItem) Title() string { 15 | return i.module.Terrafile.Dir 16 | } 17 | 18 | func (i listItem) Description() string { 19 | return i.renderSummary() 20 | } 21 | 22 | func (i listItem) FilterValue() string { 23 | return i.module.Terrafile.Dir 24 | } 25 | 26 | func (i listItem) renderSummary() string { 27 | var ( 28 | summary strings.Builder 29 | module = i.module 30 | run = module.Run 31 | drift *runner.Drift 32 | ) 33 | if run != nil { 34 | drift = run.Drift() 35 | } 36 | switch { 37 | case module.Skip(): 38 | summary.WriteString("Skip.") 39 | case run == nil: 40 | summary.WriteString("Not run.") 41 | case run.IsRunning(): 42 | summary.WriteString("Running...") 43 | case run.HasError(): 44 | summary.WriteString("Error occurred.") 45 | case run.IsApplied(): 46 | summary.WriteString("Applied.") 47 | case !run.HasPlan(): 48 | summary.WriteString("No plan available.") 49 | case !drift.HasDrift(): 50 | summary.WriteString("No changes.") 51 | default: 52 | // There is a plan, and there is drift, so show a summary 53 | fmt.Fprintf( 54 | &summary, 55 | "+ %d ~ %d - %d", 56 | len(drift.AddResources), 57 | len(drift.ChangeResources), 58 | len(drift.DestroyResources), 59 | ) 60 | } 61 | return summary.String() 62 | } 63 | -------------------------------------------------------------------------------- /tui/modulesui/keys.go: -------------------------------------------------------------------------------- 1 | package modulesui 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var keys = keymap{ 6 | enter: key.NewBinding( 7 | key.WithKeys("enter"), 8 | key.WithHelp("enter", "select"), 9 | ), 10 | colon: key.NewBinding( 11 | key.WithKeys(":"), 12 | key.WithHelp(":", "command mode"), 13 | ), 14 | esc: key.NewBinding( 15 | key.WithKeys("esc"), 16 | key.WithHelp("esc", "back to list"), 17 | ), 18 | run: key.NewBinding( 19 | key.WithKeys(" "), 20 | key.WithHelp("␣", "run selected"), 21 | ), 22 | // runAll: key.NewBinding( 23 | // key.WithKeys("ctrl+r"), 24 | // key.WithHelp("ctrl+r", "run all visible"), 25 | // ), 26 | tab: key.NewBinding( 27 | key.WithKeys("tab"), 28 | key.WithHelp("tab", "select section"), 29 | ), 30 | filterDrift: key.NewBinding( 31 | key.WithKeys("D"), 32 | key.WithHelp("D", "filter drift"), 33 | ), 34 | filterError: key.NewBinding( 35 | key.WithKeys("E"), 36 | key.WithHelp("E", "filter error"), 37 | ), 38 | toggleSummary: key.NewBinding( 39 | key.WithKeys("S"), 40 | key.WithHelp("S", "toggle summary"), 41 | ), 42 | togglePagination: key.NewBinding( 43 | key.WithKeys("P"), 44 | key.WithHelp("P", "toggle pagination"), 45 | ), 46 | toggleHelpMenu: key.NewBinding( 47 | key.WithKeys("H"), 48 | key.WithHelp("H", "toggle help"), 49 | ), 50 | } 51 | 52 | type keymap struct { 53 | enter key.Binding 54 | esc key.Binding 55 | run key.Binding 56 | // runAll key.Binding 57 | tab key.Binding 58 | colon key.Binding 59 | filterDrift key.Binding 60 | filterError key.Binding 61 | toggleSummary key.Binding 62 | togglePagination key.Binding 63 | toggleHelpMenu key.Binding 64 | } 65 | 66 | func (k keymap) ShortHelp() []key.Binding { 67 | return []key.Binding{ 68 | k.enter, k.colon, k.run, k.tab, k.filterDrift, 69 | } 70 | } 71 | 72 | func (k keymap) FullHelpKeys() []key.Binding { 73 | return []key.Binding{ 74 | k.enter, k.colon, k.run, k.tab, k.filterDrift, k.filterError, k.toggleSummary, 75 | k.togglePagination, k.toggleHelpMenu, 76 | } 77 | } 78 | 79 | var inputKeys = inputKeyMap{ 80 | build: key.NewBinding( 81 | key.WithKeys("b"), 82 | key.WithHelp("b", "build"), 83 | ), 84 | init: key.NewBinding( 85 | key.WithKeys("i"), 86 | key.WithHelp("i", "init"), 87 | ), 88 | upgrade: key.NewBinding( 89 | key.WithKeys("u"), 90 | key.WithHelp("u", "upgrade"), 91 | ), 92 | plan: key.NewBinding( 93 | key.WithKeys("p"), 94 | key.WithHelp("p", "plan"), 95 | ), 96 | apply: key.NewBinding( 97 | key.WithKeys("a"), 98 | key.WithHelp("a", "apply"), 99 | ), 100 | all: key.NewBinding( 101 | key.WithKeys("A"), 102 | key.WithHelp("A", "all modules"), 103 | ), 104 | } 105 | 106 | type inputKeyMap struct { 107 | build key.Binding 108 | init key.Binding 109 | upgrade key.Binding 110 | plan key.Binding 111 | apply key.Binding 112 | all key.Binding 113 | } 114 | 115 | func (k inputKeyMap) ShortHelp() []key.Binding { 116 | return []key.Binding{ 117 | k.build, k.init, k.upgrade, k.plan, k.apply, k.all, 118 | } 119 | } 120 | func (k inputKeyMap) FullHelp() [][]key.Binding { 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /tui/modulesui/state.go: -------------------------------------------------------------------------------- 1 | package modulesui 2 | 3 | type state int 4 | 5 | const ( 6 | listView state = iota 7 | summaryView 8 | ) 9 | -------------------------------------------------------------------------------- /tui/modulesui/styles.go: -------------------------------------------------------------------------------- 1 | package modulesui 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/muesli/termenv" 6 | ) 7 | 8 | var ( 9 | labelStyle = lipgloss.NewStyle(). 10 | Padding(0, 1).MarginBottom(1) 11 | appliedLabel = labelStyle.Copy(). 12 | Background(lipgloss.Color(termenv.ANSIGreen.String())). 13 | Render 14 | noChangesLabel = labelStyle.Copy(). 15 | Background(lipgloss.Color(termenv.ANSIGreen.String())). 16 | Render 17 | driftLabel = labelStyle.Copy(). 18 | Background(lipgloss.Color(termenv.ANSIYellow.String())). 19 | Render 20 | noPlanLabel = labelStyle.Copy(). 21 | Background(lipgloss.Color(termenv.ANSICyan.String())). 22 | Render 23 | inProgress = labelStyle.Copy(). 24 | Background(lipgloss.Color(termenv.ANSIMagenta.String())). 25 | Render 26 | errorLabel = labelStyle.Copy(). 27 | Background(lipgloss.Color(termenv.ANSIRed.String())). 28 | Render 29 | 30 | boldStyle = lipgloss.NewStyle().Bold(true) 31 | viewportContentStyle = lipgloss.NewStyle().Padding(0, 2) 32 | 33 | addSymbol = lipgloss.NewStyle(). 34 | Foreground(lipgloss.Color(termenv.ANSIGreen.String())). 35 | Render("+") 36 | changeSymbol = lipgloss.NewStyle(). 37 | Foreground(lipgloss.Color(termenv.ANSIYellow.String())). 38 | Render("~") 39 | destroySymbol = lipgloss.NewStyle(). 40 | Foreground(lipgloss.Color(termenv.ANSIRed.String())). 41 | Render("-") 42 | replaceSymbol = destroySymbol + addSymbol 43 | 44 | dimmedColor = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} 45 | ) 46 | -------------------------------------------------------------------------------- /tui/state.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | type state int 4 | 5 | const ( 6 | modulesView state = iota 7 | entryView 8 | ) 9 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/README.md: -------------------------------------------------------------------------------- 1 | # Tutorial: multiple root modules (finished) 2 | 3 | See the tutorial online: 4 | 5 | Or as [Markdown](../../docs/tutorials/multiple-root-modules.md) 6 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/local/dev/main.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "local_file" "this" { 3 | content = "env = ${local.environment}" 4 | filename = "${path.module}/${local.environment}.txt" 5 | } 6 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/local/dev/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | locals { 3 | environment = "dev" 4 | } 5 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/local/prod/main.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "local_file" "this" { 3 | content = "env = ${local.environment}" 4 | filename = "${path.module}/${local.environment}.txt" 5 | } 6 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/local/prod/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | locals { 3 | environment = "prod" 4 | } 5 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/local/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "provider_local" { 3 | contents = read_template("provider_local.tmpl") 4 | } 5 | 6 | terraform { 7 | required_providers { 8 | local = { 9 | source = "hashicorp/local" 10 | version = "2.1.0" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/templates/backend_local.tmpl: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | backend "local" { 4 | path = "terraform.tfstate" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/templates/provider_local.tmpl: -------------------------------------------------------------------------------- 1 | 2 | provider "local" { 3 | # No configuration required 4 | } 5 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules-finished/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | template "backend" { 3 | contents = read_template("backend_local.tmpl") 4 | } 5 | 6 | terraform { 7 | required_version = ">= 1.0" 8 | } 9 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules/README.md: -------------------------------------------------------------------------------- 1 | # Tutorial: multiple root modules 2 | 3 | See the tutorial online: 4 | 5 | Or as [Markdown](../../docs/tutorials/multiple-root-modules.md) 6 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules/backend.tf: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | backend "local" { 4 | path = "terraform.tfstate" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules/main.tf: -------------------------------------------------------------------------------- 1 | 2 | provider "local" { 3 | # No configuration required 4 | } 5 | 6 | # Create our dev environment 7 | resource "local_file" "dev" { 8 | content = "env = dev" 9 | filename = "${path.module}/dev.txt" 10 | } 11 | 12 | # Create our prod environment 13 | resource "local_file" "prod" { 14 | content = "env = prod" 15 | filename = "${path.module}/prod.txt" 16 | } 17 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules/terraplate.hcl: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tutorials/multiple-root-modules/versions.tf: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | required_version = ">= 1.0" 4 | 5 | required_providers { 6 | local = { 7 | source = "hashicorp/local" 8 | version = "2.1.0" 9 | } 10 | } 11 | } 12 | --------------------------------------------------------------------------------