├── .github └── workflows │ ├── build-test.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── example ├── example.txt └── screenshot.png ├── go.mod ├── go.sum ├── main.go └── main_test.go /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Go Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up Go 1.x 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ^1.13 19 | id: go 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v2 22 | - name: Get dependencies 23 | run: | 24 | go get -v -t -d ./... 25 | - name: Build 26 | run: go build -v . 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Handle Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | generate: 9 | name: Create release-artifacts 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up Go 1.x 13 | uses: actions/setup-go@v2 14 | with: 15 | go-version: ^1.13 16 | id: go 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@v2 19 | - name: Get dependencies 20 | run: go get -v -t -d ./... 21 | 22 | - name: Build Linux 23 | run: env GOOS=linux go build -o bin/tfarbe-linux -v . 24 | 25 | - name: Build Mac 26 | run: env GOOS=darwin go build -o bin/tfarbe-darwin -v . 27 | 28 | - name: Upload the artifacts 29 | uses: skx/github-action-publish-binaries@master 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | with: 33 | args: 'bin/tfarbe*' 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | bin/ 3 | tfarbe 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14-alpine 2 | 3 | RUN mkdir /app 4 | ADD . /app 5 | WORKDIR /app 6 | 7 | RUN go build -o tfarbe . 8 | 9 | ENTRYPOINT ["./tfarbe"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 jeff-knurek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tfarbe 2 | Add color to Terraform 12 plan output. 3 | 4 | Inspired from: https://github.com/coinbase/terraform-landscape 5 | 6 | **NOTE**: if you're using terraform v11, tfarbe will not help and you should use `terraform-landscape`. 7 | 8 | ### Example of output 9 | 10 | Improved Terraform plan output 11 | 12 | Also formats the ouput for markdown diff. 13 | 14 | For example, this: 15 | ``` 16 | # module.apps.kubernetes_deployment.deployment is tainted, so must be replaced 17 | -/+ resource "kubernetes_deployment" "deployment" { 18 | ~ id = "some-app" -> (known after apply) 19 | wait_for_rollout = true 20 | 21 | ~ spec { 22 | - active_deadline_seconds = 0 -> null 23 | - automount_service_account_token = false -> null 24 | dns_policy = "ClusterFirst" 25 | host_ipc = false 26 | host_network = false 27 | host_pid = false 28 | + hostname = (known after apply) 29 | + node_name = (known after apply) 30 | restart_policy = "Always" 31 | + service_account_name = (known after apply) 32 | ``` 33 | 34 | becomes: 35 | 36 | ```diff 37 | # module.apps.kubernetes_deployment.deployment is tainted, so must be replaced 38 | -/+ resource "kubernetes_deployment" "deployment" { 39 | ~ id = "some-app" -> (known after apply) 40 | wait_for_rollout = true 41 | 42 | ~ spec { 43 | - active_deadline_seconds = 0 -> null 44 | - automount_service_account_token = false -> null 45 | dns_policy = "ClusterFirst" 46 | host_ipc = false 47 | host_network = false 48 | host_pid = false 49 | + hostname = (known after apply) 50 | + node_name = (known after apply) 51 | restart_policy = "Always" 52 | + service_account_name = (known after apply) 53 | ``` 54 | 55 | ## Install 56 | 57 | Binaries (should) be available on the [Release page](https://github.com/jeff-knurek/tfarbe/releases/) for both Linux and Mac. You can simple copy one of these binaries to your PATH. 58 | 59 | ## Usage 60 | 61 | ``` 62 | terraform plan ... | tfarbe 63 | ``` 64 | 65 | ### with Docker 66 | 67 | ``` 68 | git clone https://github.com/jeff-knurek/tfarbe.git 69 | cd tfarbe 70 | docker build . -t tfarbe 71 | .... 72 | terraform plan ... | docker run -i --rm tfarbe 73 | ``` 74 | 75 | ### Helpful bash addition 76 | 77 | Add this to your `.bash_profile`/`.bashrc`/`...` accordingly 78 | 79 | ``` 80 | terraform() { 81 | if [[ $1 == "plan" ]]; then 82 | command terraform "$@" | docker run --rm -i tfarbe 83 | else 84 | command terraform "$@" 85 | fi 86 | } 87 | ``` 88 | 89 | ## License 90 | 91 | This project is released under the [MIT license](LICENSE). 92 | -------------------------------------------------------------------------------- /example/example.txt: -------------------------------------------------------------------------------- 1 | # module.apps.kubernetes_service.service will be updated in-place 2 | ~ resource "kubernetes_service" "service" { 3 | id = "some-app" 4 | load_balancer_ingress = [] 5 | 6 | ~ metadata { 7 | annotations = {} 8 | generation = 0 9 | ~ labels = { 10 | "app.kubernetes.io/managed-by" = "terraform" 11 | ~ "app.kubernetes.io/version" = "latest" -> "1.0.1" 12 | } 13 | 14 | # module.apps.kubernetes_deployment.deployment is tainted, so must be replaced 15 | -/+ resource "kubernetes_deployment" "deployment" { 16 | ~ id = "some-app" -> (known after apply) 17 | wait_for_rollout = true 18 | 19 | ~ spec { 20 | - active_deadline_seconds = 0 -> null 21 | - automount_service_account_token = false -> null 22 | dns_policy = "ClusterFirst" 23 | host_ipc = false 24 | host_network = false 25 | host_pid = false 26 | + hostname = (known after apply) 27 | + node_name = (known after apply) 28 | restart_policy = "Always" 29 | + service_account_name = (known after apply) 30 | 31 | # local_file.foo will be created 32 | + resource "local_file" "foo" { 33 | + content = <<~EOT 34 | can: 35 | enter: 36 | - yaml 37 | - or 38 | + anything 39 | EOT 40 | + directory_permission = "0777" 41 | + file_permission = "0777" 42 | + filename = "./foo.out" 43 | + id = (known after apply) 44 | } 45 | -------------------------------------------------------------------------------- /example/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeff-knurek/tfarbe/e5ed0a2a29884f4c1025576666964ed9ab19ed31/example/screenshot.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tfarbe 2 | 3 | go 1.14 4 | 5 | require github.com/logrusorgru/aurora v2.0.3+incompatible 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= 2 | github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "regexp" 9 | "strings" 10 | 11 | . "github.com/logrusorgru/aurora" 12 | ) 13 | 14 | func main() { 15 | iterateInput(os.Stdin, os.Stdout) 16 | } 17 | 18 | func iterateInput(input io.Reader, out io.Writer) { 19 | skipLines := false 20 | key := "" 21 | scanner := bufio.NewScanner(input) 22 | for scanner.Scan() { 23 | ln := scanner.Text() 24 | if strings.TrimSpace(ln) == key { 25 | skipLines = false 26 | } 27 | if skipLines { 28 | fmt.Fprintln(out, ln) 29 | } else { 30 | processLine(ln, out) 31 | } 32 | if strings.Contains(ln, "<<~") { 33 | skipLines = true 34 | key = after(ln, "<<~") 35 | } 36 | } 37 | return 38 | } 39 | 40 | // process in-coming text 41 | func processLine(raw string, out io.Writer) { 42 | var toPrint interface{} 43 | cleaned := cleanRawInput(raw) 44 | if len(cleaned) < 1 && len(raw) > 0 { 45 | // filtered text shouldn't print a new line 46 | return 47 | } 48 | 49 | trimmed := strings.TrimSpace(cleaned) 50 | if len(trimmed) < 1 { 51 | fmt.Fprintln(out, raw) 52 | return 53 | } 54 | 55 | firstChar := string(trimmed[0]) 56 | switch firstChar { 57 | case "~": 58 | ch := strings.Replace(cleaned, "~", "", 1) 59 | sp := strings.SplitAfter(ch, " -> ") 60 | if len(sp) != 2 { 61 | toPrint = Yellow("~" + ch) 62 | break 63 | } 64 | new := sp[1] 65 | sp2 := strings.SplitAfter(sp[0], " = ") 66 | if len(sp2) != 2 { 67 | toPrint = Yellow("~" + ch) 68 | break 69 | } 70 | ch = "~" + sp2[0] 71 | old := sp2[1] 72 | toPrint = fmt.Sprintf("%s%s%s", Yellow(ch), Red(old), Green(new)) 73 | case "+": 74 | new := strings.Replace(cleaned, "+", "", 1) 75 | toPrint = Green("+" + new) 76 | case "-": 77 | new := strings.Replace(cleaned, "-", "", 1) 78 | toPrint = Red("-" + new) 79 | case "#": 80 | toPrint = trimmed 81 | default: 82 | toPrint = raw 83 | } 84 | fmt.Fprintln(out, toPrint) 85 | return 86 | } 87 | 88 | func cleanRawInput(raw string) string { 89 | ansi := "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 90 | re := regexp.MustCompile(ansi) 91 | nocolor := re.ReplaceAllString(raw, "") 92 | 93 | refreshing := "Refreshing state... " 94 | if strings.Contains(nocolor, refreshing) { 95 | return "" 96 | } 97 | return nocolor 98 | } 99 | 100 | // Get substring after a string. 101 | func after(value string, a string) string { 102 | pos := strings.LastIndex(value, a) 103 | if pos == -1 { 104 | return "" 105 | } 106 | adjustedPos := pos + len(a) 107 | if adjustedPos >= len(value) { 108 | return "" 109 | } 110 | return value[adjustedPos:] 111 | } 112 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func Test_processLine(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | text string 12 | out *bytes.Buffer 13 | want string 14 | }{ 15 | { 16 | name: "empty", 17 | text: "", 18 | out: &bytes.Buffer{}, 19 | want: ` 20 | `, 21 | }, 22 | { 23 | name: "whitespace", 24 | text: " ", 25 | out: &bytes.Buffer{}, 26 | want: " " + ` 27 | `, 28 | }, 29 | { 30 | name: "new resource", 31 | text: " # module.example ", 32 | out: &bytes.Buffer{}, 33 | want: `# module.example 34 | `, 35 | }, 36 | { 37 | name: "no change", 38 | text: " id = \"some string\"", 39 | out: &bytes.Buffer{}, 40 | want: ` id = "some string" 41 | `, 42 | }, 43 | { 44 | name: "added line", 45 | text: " + id = \"some string\"", 46 | out: &bytes.Buffer{}, 47 | want: `+ id = "some string" 48 | `, 49 | }, 50 | { 51 | name: "removed line", 52 | text: " - item = 0 -> null", 53 | out: &bytes.Buffer{}, 54 | want: `- item = 0 -> null 55 | `, 56 | }, 57 | { 58 | name: "changed line", 59 | text: " ~ \"new/version\" = \"latest\" -> \"1.0.1\"", 60 | out: &bytes.Buffer{}, 61 | want: `~ "new/version" = "latest" -> "1.0.1" 62 | `, 63 | }, 64 | { 65 | name: "complex changed line", 66 | text: " ~ \"new/version = some -> thing\" = \"latest\" -> \"1.0.1\"", 67 | out: &bytes.Buffer{}, 68 | want: `~ "new/version = some -> thing" = "latest" -> "1.0.1" 69 | `, 70 | }, 71 | { 72 | name: "cannot edit in place", 73 | text: " - item = 0 -> null", 74 | out: &bytes.Buffer{}, 75 | want: `- item = 0 -> null 76 | `, 77 | }, 78 | { 79 | name: "pre-existing color", 80 | text: " + id = \"some string\"", 81 | out: &bytes.Buffer{}, 82 | want: `+ id = "some string" 83 | `, 84 | }, 85 | { 86 | name: "filter refreshing state", 87 | text: "module.apps.kubernetes_config_map.config-map: Refreshing state... [id=id1]", 88 | out: &bytes.Buffer{}, 89 | want: "", //no new line 90 | }, 91 | } 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | processLine(tt.text, tt.out) 95 | got := tt.out.String() 96 | if got != tt.want { 97 | t.Errorf("output is now aligned. \nGot: %v, \nwant: %v", got, tt.want) 98 | // t.Errorf("length \nGot: %v, \nwant: %v", len(got), len(tt.want)) 99 | } 100 | }) 101 | } 102 | } 103 | 104 | func Test_cleanRawInput(t *testing.T) { 105 | type args struct { 106 | } 107 | tests := []struct { 108 | name string 109 | raw string 110 | want string 111 | }{ 112 | { 113 | name: "only whitespace", 114 | raw: " ", 115 | want: " ", 116 | }, 117 | { 118 | name: "no color with space", 119 | raw: " id = \"some string\" ", 120 | want: " id = \"some string\" ", 121 | }, 122 | { 123 | name: "partial colored", 124 | raw: " + id = \"some string\"", 125 | want: " + id = \"some string\"", 126 | }, 127 | } 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | if got := cleanRawInput(tt.raw); got != tt.want { 131 | t.Errorf("cleanRawInput(%s) \ngot: %v, \nwant: %v", tt.name, got, tt.want) 132 | } 133 | }) 134 | } 135 | } 136 | 137 | func Test_iterateInput(t *testing.T) { 138 | tests := []struct { 139 | name string 140 | input string 141 | out *bytes.Buffer 142 | wantOut string 143 | }{ 144 | { 145 | name: "empty", 146 | input: "", 147 | out: &bytes.Buffer{}, 148 | wantOut: ``, 149 | }, 150 | { 151 | name: "example", 152 | input: ` 153 | # module.apps.kubernetes_deployment.deployment is tainted, so must be replaced 154 | -/+ resource "kubernetes_deployment" "deployment" { 155 | ~ id = "some-app" -> (known after apply) 156 | wait_for_rollout = true 157 | 158 | ~ spec { 159 | - active_deadline_seconds = 0 -> null 160 | `, 161 | out: &bytes.Buffer{}, 162 | wantOut: ` 163 | # module.apps.kubernetes_deployment.deployment is tainted, so must be replaced 164 | -/+ resource "kubernetes_deployment" "deployment" { 165 | ~ id = "some-app" -> (known after apply) 166 | wait_for_rollout = true 167 | 168 | ~ spec { 169 | - active_deadline_seconds = 0 -> null 170 | `, 171 | }, 172 | { 173 | name: "multiline example", 174 | input: ` 175 | # local_file.foo will be created 176 | + resource "local_file" "foo" { 177 | + content = <<~EOT 178 | can: 179 | enter: 180 | - yaml 181 | - or 182 | + anything 183 | EOT 184 | + directory_permission = "0777" 185 | `, 186 | out: &bytes.Buffer{}, 187 | wantOut: ` 188 | # local_file.foo will be created 189 | + resource "local_file" "foo" { 190 | + content = <<~EOT 191 | can: 192 | enter: 193 | - yaml 194 | - or 195 | + anything 196 | EOT 197 | + directory_permission = "0777" 198 | `, 199 | }, 200 | } 201 | for _, tt := range tests { 202 | t.Run(tt.name, func(t *testing.T) { 203 | var stdin bytes.Buffer 204 | stdin.Write([]byte(tt.input)) 205 | iterateInput(&stdin, tt.out) 206 | got := tt.out.String() 207 | if got != tt.wantOut { 208 | t.Errorf("output for %s is now aligned. \nGot: %v, \nwant: %v", tt.name, got, tt.wantOut) 209 | t.Errorf("length \nGot: %v, \nwant: %v", len(got), len(tt.wantOut)) 210 | } 211 | }) 212 | } 213 | } 214 | --------------------------------------------------------------------------------