├── .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 |
2 |
3 | > DRY Terraform with Go Templates
4 |
5 | [](https://goreportcard.com/report/github.com/verifa/terraplate)
6 | [](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 |
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 | [](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 |
20 |
--------------------------------------------------------------------------------
/docs/assets/logo/terraplate-verifa.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/assets/logo/terraplate-white.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
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 |
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 | 
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 | 
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 | 
48 |
49 | ### Drift detected
50 |
51 | 
52 |
53 | ### Error occurred
54 |
55 | 
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 |
--------------------------------------------------------------------------------