diff --git a/tfexec/errors.go b/tfexec/errors.go index 241fe715..010b3887 100644 --- a/tfexec/errors.go +++ b/tfexec/errors.go @@ -29,7 +29,8 @@ var ( ) func (tf *Terraform) parseError(err error, stderr string) error { - if _, ok := err.(*exec.ExitError); !ok { + ee, ok := err.(*exec.ExitError) + if !ok { return err } @@ -87,6 +88,11 @@ func (tf *Terraform) parseError(err error, stderr string) error { return &ErrWorkspaceExists{submatches[1]} } } + errString := strings.TrimSpace(stderr) + if errString == "" { + // if stderr is empty, return the ExitError directly, as it will have a better message + return ee + } return errors.New(stderr) } diff --git a/tfexec/fmt.go b/tfexec/fmt.go new file mode 100644 index 00000000..64473a5e --- /dev/null +++ b/tfexec/fmt.go @@ -0,0 +1,149 @@ +package tfexec + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +type formatConfig struct { + recursive bool + dir string +} + +var defaultFormatConfig = formatConfig{ + recursive: false, +} + +type FormatOption interface { + configureFormat(*formatConfig) +} + +func (opt *RecursiveOption) configureFormat(conf *formatConfig) { + conf.recursive = opt.recursive +} + +func (opt *DirOption) configureFormat(conf *formatConfig) { + conf.dir = opt.path +} + +// FormatString formats a passed string, given a path to Terraform. +func FormatString(ctx context.Context, execPath string, content string) (string, error) { + tf, err := NewTerraform(filepath.Dir(execPath), execPath) + if err != nil { + return "", err + } + + return tf.FormatString(ctx, content) +} + +// FormatString formats a passed string. +func (tf *Terraform) FormatString(ctx context.Context, content string) (string, error) { + cmd, err := tf.formatCmd(ctx, nil, Dir("-")) + if err != nil { + return "", err + } + + cmd.Stdin = strings.NewReader(content) + + var outBuf bytes.Buffer + cmd.Stdout = mergeWriters(cmd.Stdout, &outBuf) + + err = tf.runTerraformCmd(cmd) + if err != nil { + return "", err + } + + return outBuf.String(), nil +} + +// FormatWrite attempts to format and modify all config files in the working or selected (via DirOption) directory. +func (tf *Terraform) FormatWrite(ctx context.Context, opts ...FormatOption) error { + for _, o := range opts { + switch o := o.(type) { + case *DirOption: + if o.path == "-" { + return fmt.Errorf("a path of \"-\" is not supported for this method, please use FormatString") + } + } + } + + cmd, err := tf.formatCmd(ctx, []string{"-write=true", "-list=false", "-diff=false"}, opts...) + if err != nil { + return err + } + + return tf.runTerraformCmd(cmd) +} + +// FormatCheck returns true if the config files in the working or selected (via DirOption) directory are already formatted. +func (tf *Terraform) FormatCheck(ctx context.Context, opts ...FormatOption) (bool, []string, error) { + for _, o := range opts { + switch o := o.(type) { + case *DirOption: + if o.path == "-" { + return false, nil, fmt.Errorf("a path of \"-\" is not supported for this method, please use FormatString") + } + } + } + + cmd, err := tf.formatCmd(ctx, []string{"-write=false", "-list=true", "-diff=false", "-check=true"}, opts...) + if err != nil { + return false, nil, err + } + + var outBuf bytes.Buffer + cmd.Stdout = mergeWriters(cmd.Stdout, &outBuf) + + err = tf.runTerraformCmd(cmd) + if err == nil { + return true, nil, nil + } + if cmd.ProcessState.ExitCode() == 3 { + // unformatted, parse the file list + + files := []string{} + lines := strings.Split(strings.Replace(outBuf.String(), "\r\n", "\n", -1), "\n") + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + continue + } + files = append(files, l) + } + + return false, files, nil + } + return false, nil, err +} + +func (tf *Terraform) formatCmd(ctx context.Context, args []string, opts ...FormatOption) (*exec.Cmd, error) { + c := defaultFormatConfig + + for _, o := range opts { + switch o.(type) { + case *RecursiveOption: + err := tf.compatible(ctx, tf0_12_0, nil) + if err != nil { + return nil, fmt.Errorf("-recursive was added to fmt in Terraform 0.12: %w", err) + } + } + + o.configureFormat(&c) + } + + args = append([]string{"fmt", "-no-color"}, args...) + + if c.recursive { + args = append(args, "-recursive") + } + + if c.dir != "" { + args = append(args, c.dir) + } + + return tf.buildTerraformCmd(ctx, nil, args...), nil +} diff --git a/tfexec/internal/e2etest/fmt_test.go b/tfexec/internal/e2etest/fmt_test.go new file mode 100644 index 00000000..f081b007 --- /dev/null +++ b/tfexec/internal/e2etest/fmt_test.go @@ -0,0 +1,86 @@ +package e2etest + +import ( + "context" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/hashicorp/go-version" + + "github.com/hashicorp/terraform-exec/tfexec" +) + +func TestFormatString(t *testing.T) { + runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + unformatted := strings.TrimSpace(` +resource "foo" "bar" { + baz = 1 + qux = 2 +} +`) + + expected := strings.TrimSpace(` +resource "foo" "bar" { + baz = 1 + qux = 2 +} +`) + + actual, err := tf.FormatString(context.Background(), unformatted) + if err != nil { + t.Fatal(err) + } + + actual = strings.TrimSpace(actual) + + if actual != expected { + t.Fatalf("expected:\n%s\ngot:\n%s\n", expected, actual) + } + }) +} + +func TestFormatCheck(t *testing.T) { + runTest(t, "unformatted", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + checksums := map[string]uint32{ + "file1.tf": checkSum(t, filepath.Join(tf.WorkingDir(), "file1.tf")), + "file2.tf": checkSum(t, filepath.Join(tf.WorkingDir(), "file2.tf")), + } + + formatted, files, err := tf.FormatCheck(context.Background()) + if err != nil { + t.Fatalf("error from FormatCheck: %T %q", err, err) + } + + if formatted { + t.Fatal("expected unformatted") + } + + if !reflect.DeepEqual(files, []string{"file1.tf", "file2.tf"}) { + t.Fatalf("unexpected files list: %#v", files) + } + + for file, checksum := range checksums { + if checksum != checkSum(t, filepath.Join(tf.WorkingDir(), file)) { + t.Fatalf("%s should not have changed", file) + } + } + }) +} + +func TestFormatWrite(t *testing.T) { + runTest(t, "unformatted", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + err := tf.FormatWrite(context.Background()) + if err != nil { + t.Fatalf("error from FormatWrite: %T %q", err, err) + } + + for file, golden := range map[string]string{ + "file1.tf": "file1.golden.txt", + "file2.tf": "file2.golden.txt", + } { + textFilesEqual(t, filepath.Join(tf.WorkingDir(), golden), filepath.Join(tf.WorkingDir(), file)) + } + }) +} diff --git a/tfexec/internal/e2etest/testdata/unformatted/file1.golden.txt b/tfexec/internal/e2etest/testdata/unformatted/file1.golden.txt new file mode 100644 index 00000000..630705e3 --- /dev/null +++ b/tfexec/internal/e2etest/testdata/unformatted/file1.golden.txt @@ -0,0 +1,4 @@ +resource "foo" "bar" { + baz = 1 + qux = 2 +} diff --git a/tfexec/internal/e2etest/testdata/unformatted/file1.tf b/tfexec/internal/e2etest/testdata/unformatted/file1.tf new file mode 100644 index 00000000..07c72726 --- /dev/null +++ b/tfexec/internal/e2etest/testdata/unformatted/file1.tf @@ -0,0 +1,4 @@ +resource "foo" "bar" { + baz = 1 + qux = 2 +} diff --git a/tfexec/internal/e2etest/testdata/unformatted/file2.golden.txt b/tfexec/internal/e2etest/testdata/unformatted/file2.golden.txt new file mode 100644 index 00000000..b5dd748a --- /dev/null +++ b/tfexec/internal/e2etest/testdata/unformatted/file2.golden.txt @@ -0,0 +1,4 @@ +resource "foo" "baz" { + baz = 1 + qux = 2 +} diff --git a/tfexec/internal/e2etest/testdata/unformatted/file2.tf b/tfexec/internal/e2etest/testdata/unformatted/file2.tf new file mode 100644 index 00000000..8913b1ad --- /dev/null +++ b/tfexec/internal/e2etest/testdata/unformatted/file2.tf @@ -0,0 +1,4 @@ +resource "foo" "baz" { + baz = 1 + qux = 2 +} diff --git a/tfexec/internal/e2etest/util_test.go b/tfexec/internal/e2etest/util_test.go index ae43e604..d5232a92 100644 --- a/tfexec/internal/e2etest/util_test.go +++ b/tfexec/internal/e2etest/util_test.go @@ -1,10 +1,9 @@ package e2etest import ( - "bufio" - "bytes" "context" "fmt" + "hash/crc32" "io" "io/ioutil" "os" @@ -163,27 +162,33 @@ func copyFile(path string, dstPath string) error { return nil } -// filesEqual returns true iff the two files have the same contents. -func filesEqual(file1, file2 string) (bool, error) { - sf, err := os.Open(file1) +// filesEqual asserts that two files have the same contents. +func textFilesEqual(t *testing.T, expected, actual string) { + eb, err := ioutil.ReadFile(expected) if err != nil { - return false, err + t.Fatal(err) } - df, err := os.Open(file2) + ab, err := ioutil.ReadFile(actual) if err != nil { - return false, err + t.Fatal(err) } - sscan := bufio.NewScanner(sf) - dscan := bufio.NewScanner(df) + es := string(eb) + es = strings.ReplaceAll(es, "\r\n", "\n") - for sscan.Scan() { - dscan.Scan() - if !bytes.Equal(sscan.Bytes(), dscan.Bytes()) { - return true, nil - } + as := string(ab) + as = strings.ReplaceAll(as, "\r\n", "\n") + + if as != es { + t.Fatalf("expected:\n%s\n\ngot:\n%s\n", es, as) } +} - return false, nil +func checkSum(t *testing.T, filename string) uint32 { + b, err := ioutil.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + return crc32.ChecksumIEEE(b) } diff --git a/tfexec/options.go b/tfexec/options.go index 4497f7f2..f5baf5d4 100644 --- a/tfexec/options.go +++ b/tfexec/options.go @@ -217,6 +217,14 @@ func Reconfigure(reconfigure bool) *ReconfigureOption { return &ReconfigureOption{reconfigure} } +type RecursiveOption struct { + recursive bool +} + +func Recursive(r bool) *RecursiveOption { + return &RecursiveOption{r} +} + type RefreshOption struct { refresh bool }