astro/terraform/policy_diff.go (121 lines of code) (raw):

/* * Copyright (c) 2018 Uber Technologies, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package terraform import ( "bytes" "encoding/json" "fmt" "io/ioutil" "os" "os/exec" "regexp" "strings" "syscall" multierror "github.com/hashicorp/go-multierror" ) var ( // Full path to differ will be stored here on init differPath string // $PATH will be searched for these tools on init differTools = []string{ "colordiff", "diff", } newline = []byte("\n") // regular expressions that matches a policy add/change in a Terraform diff. terraformPolicyAddLine = regexp.MustCompile(`\s*policy:\s+"(.*)"`) terraformPolicyChangeLine = regexp.MustCompile(`\s*policy:\s+"(.*)" => "(.*)"`) ) func init() { differPath, _ = which(differTools) } // terraformPolicyChangeToDiff takes a Terraform policy change output line // (i.e. from a Terraform plan) parses the JSON and outputs a unified diff. func terraformPolicyChangeToDiff(differ, policyBefore, policyAfter string) ([]byte, error) { jsonBefore, err := jsonPretty(unescape(policyBefore)) if err != nil { return nil, err } before, err := writeToTempFile(jsonBefore) if err != nil { return nil, err } defer os.Remove(before) jsonAfter, err := jsonPretty(unescape(policyAfter)) if err != nil { return nil, err } after, err := writeToTempFile(jsonAfter) if err != nil { return nil, err } defer os.Remove(after) return diff(differ, before, after) } // diff invokes diff to output a diff of two files. func diff(differ, file1, file2 string) ([]byte, error) { cmd := exec.Command(differ, "-u", file1, file2) out, err := cmd.Output() // We only want to throw an error here if the exit status was 2 or // higher. From the diff man page: "Exit status is 0 if inputs are the // same, 1 if different, 2 if trouble." if err != nil { exitErr, ok := err.(*exec.ExitError) if !ok { return nil, err } status, ok := exitErr.Sys().(syscall.WaitStatus) if !ok || status.ExitStatus() > 1 { return nil, err } } return out, nil } // jsonPretty takes unformatted JSON and indents it so it is human readable. If // the JSON cannot be indented, the original JSON is returned. func jsonPretty(in []byte) ([]byte, error) { if len(in) == 0 { return in, nil } var out bytes.Buffer err := json.Indent(&out, in, "", " ") if err != nil { return nil, err } return out.Bytes(), nil } // CanDisplayReadableTerraformPolicyChanges is true when the prerequisites for // ReadableTerraformPolicyChanges are fulfilled func CanDisplayReadableTerraformPolicyChanges() bool { return differPath != "" } func readableTerraformPolicyChangesWithDiffer(differ, terraformChanges string) (string, error) { result := "" var errs error for _, line := range strings.Split(terraformChanges, "\n") { // Check if the line matches a Terraform policy diff changeGroups := terraformPolicyChangeLine.FindStringSubmatch(line) addGroups := terraformPolicyAddLine.FindStringSubmatch(line) if changeGroups == nil && addGroups == nil { // If it doesn't match, just print the line verbatim and move on result += line result += "\n" continue } // Get a readable diff from the policy change var difftext []byte var err error if changeGroups != nil { difftext, err = terraformPolicyChangeToDiff(differ, changeGroups[1], changeGroups[2]) } else { difftext, err = terraformPolicyChangeToDiff(differ, "", addGroups[1]) } if err != nil { errs = multierror.Append(errs, err) result += line result += "\n" continue } // Output a readable diff result += "\n" result += string(tail(difftext, 2, true)) result += "\n" } return result, errs } // ReadableTerraformPolicyChanges takes the output of `terraform plan` and // rewrites policy diff to be in unified diff format func ReadableTerraformPolicyChanges(terraformChanges string) (string, error) { return readableTerraformPolicyChangesWithDiffer(differPath, terraformChanges) } // tail is an implementation of the unix tail command. If fromN is true, it is // equivalent to `tail -n +K`. See `main tail` for more info. func tail(input []byte, n int, fromN bool) []byte { // split lines sub := bytes.Split(input, newline) if fromN { return bytes.Join(sub[n:], newline) } return bytes.Join(sub[len(sub)-n:], newline) } // unescape takes an escaped JSON string output by Terraform on the console // and converts it to valid JSON. func unescape(in string) []byte { out := []byte(in) out = bytes.Replace(out, []byte(`\n`), []byte("\n"), -1) out = bytes.Replace(out, []byte(`\"`), []byte(`"`), -1) out = bytes.Replace(out, []byte(`\\`), []byte(`\`), -1) return out } // writeToTempFile creates a temporary file and writes the specified data to // it. func writeToTempFile(data []byte) (filePath string, err error) { tmpfile, err := ioutil.TempFile("", "") if err != nil { return "", err } if len(data) > 0 { tmpfile.Write(data) tmpfile.Write(newline) } return tmpfile.Name(), nil } // which searches the $PATH for each of the candidates and returns the full // path to the first program that exists. func which(candidates []string) (string, error) { for _, candidate := range candidates { path, err := exec.LookPath(candidate) if err == nil { return path, nil } } return "", fmt.Errorf("cannot find any of: %v in $PATH", candidates) }