diff --git a/command/e2etest/init_test.go b/command/e2etest/init_test.go index fd5020a58d..a3b3a0b08d 100644 --- a/command/e2etest/init_test.go +++ b/command/e2etest/init_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/e2e" ) @@ -330,18 +332,17 @@ func TestInitProviderNotFound(t *testing.T) { fixturePath := filepath.Join("testdata", "provider-not-found") tf := e2e.NewBinary(terraformBin, fixturePath) - tf.AddEnv("TF_CLI_ARGS=-no-color") defer tf.Close() t.Run("registry provider not found", func(t *testing.T) { - _, stderr, err := tf.Run("init") + _, stderr, err := tf.Run("init", "-no-color") if err == nil { t.Fatal("expected error, got success") } - oneLineStderr := strings.ReplaceAll(stderr, "\n│", "") + oneLineStderr := strings.ReplaceAll(stderr, "\n", " ") if !strings.Contains(oneLineStderr, "provider registry registry.terraform.io does not have a provider named registry.terraform.io/hashicorp/nonexist") { - t.Errorf("expected error message is missing from output:\n%s", oneLineStderr) + t.Errorf("expected error message is missing from output:\n%s", stderr) } }) @@ -352,14 +353,33 @@ func TestInitProviderNotFound(t *testing.T) { t.Fatal(err) } - _, stderr, err := tf.Run("init", "-plugin-dir="+pluginDir) + _, stderr, err := tf.Run("init", "-no-color", "-plugin-dir="+pluginDir) if err == nil { t.Fatal("expected error, got success") } - oneLineStderr := strings.ReplaceAll(stderr, "\n│", "") - if !strings.Contains(oneLineStderr, "provider registry.terraform.io/hashicorp/nonexist was not found in any of the search locations - "+pluginDir) { - t.Errorf("expected error message is missing from output:\n%s", oneLineStderr) + if !strings.Contains(stderr, "provider registry.terraform.io/hashicorp/nonexist was not\nfound in any of the search locations\n\n - "+pluginDir) { + t.Errorf("expected error message is missing from output:\n%s", stderr) + } + }) + + t.Run("special characters enabled", func(t *testing.T) { + _, stderr, err := tf.Run("init") + if err == nil { + t.Fatal("expected error, got success") + } + + expectedErr := `╷ +│ Error: Failed to query available provider packages +│` + ` ` + ` +│ Could not retrieve the list of available versions for provider +│ hashicorp/nonexist: provider registry registry.terraform.io does not have a +│ provider named registry.terraform.io/hashicorp/nonexist +╵ + +` + if stripAnsi(stderr) != expectedErr { + t.Errorf("wrong output:\n%s", cmp.Diff(stripAnsi(stderr), expectedErr)) } }) } diff --git a/command/e2etest/strip_ansi.go b/command/e2etest/strip_ansi.go new file mode 100644 index 0000000000..22b66bae37 --- /dev/null +++ b/command/e2etest/strip_ansi.go @@ -0,0 +1,13 @@ +package e2etest + +import ( + "regexp" +) + +const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + +var ansiRe = regexp.MustCompile(ansi) + +func stripAnsi(str string) string { + return ansiRe.ReplaceAllString(str, "") +} diff --git a/command/format/diagnostic.go b/command/format/diagnostic.go index 104f3f3b14..d89de7a14c 100644 --- a/command/format/diagnostic.go +++ b/command/format/diagnostic.go @@ -16,6 +16,11 @@ import ( "github.com/zclconf/go-cty/cty" ) +var disabledColorize = &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, +} + // Diagnostic formats a single diagnostic message. // // The width argument specifies at what column the diagnostic messages will @@ -115,6 +120,57 @@ func Diagnostic(diag tfdiags.Diagnostic, sources map[string][]byte, color *color return ruleBuf.String() } +// DiagnosticPlain is an alternative to Diagnostic which minimises the use of +// virtual terminal formatting sequences. +// +// It is intended for use in automation and other contexts in which diagnostic +// messages are parsed from the Terraform output. +func DiagnosticPlain(diag tfdiags.Diagnostic, sources map[string][]byte, width int) string { + if diag == nil { + // No good reason to pass a nil diagnostic in here... + return "" + } + + var buf bytes.Buffer + + switch diag.Severity() { + case tfdiags.Error: + buf.WriteString("\nError: ") + case tfdiags.Warning: + buf.WriteString("\nWarning: ") + default: + buf.WriteString("\n") + } + + desc := diag.Description() + sourceRefs := diag.Source() + + // We don't wrap the summary, since we expect it to be terse, and since + // this is where we put the text of a native Go error it may not always + // be pure text that lends itself well to word-wrapping. + fmt.Fprintf(&buf, "%s\n\n", desc.Summary) + + if sourceRefs.Subject != nil { + buf = appendSourceSnippets(buf, diag, sources, disabledColorize) + } + + if desc.Detail != "" { + if width > 1 { + lines := strings.Split(desc.Detail, "\n") + for _, line := range lines { + if !strings.HasPrefix(line, " ") { + line = wordwrap.WrapString(line, uint(width-1)) + } + fmt.Fprintf(&buf, "%s\n", line) + } + } else { + fmt.Fprintf(&buf, "%s\n", desc.Detail) + } + } + + return buf.String() +} + // DiagnosticWarningsCompact is an alternative to Diagnostic for when all of // the given diagnostics are warnings and we want to show them compactly, // with only two lines per warning and excluding all of the detail information. diff --git a/command/format/diagnostic_test.go b/command/format/diagnostic_test.go index 70ee1c3902..cfbe27191b 100644 --- a/command/format/diagnostic_test.go +++ b/command/format/diagnostic_test.go @@ -230,6 +230,212 @@ func TestDiagnostic(t *testing.T) { } } +func TestDiagnosticPlain(t *testing.T) { + + tests := map[string]struct { + Diag interface{} + Want string + }{ + "sourceless error": { + tfdiags.Sourceless( + tfdiags.Error, + "A sourceless error", + "It has no source references but it does have a pretty long detail that should wrap over multiple lines.", + ), + ` +Error: A sourceless error + +It has no source references but it does +have a pretty long detail that should +wrap over multiple lines. +`, + }, + "sourceless warning": { + tfdiags.Sourceless( + tfdiags.Warning, + "A sourceless warning", + "It has no source references but it does have a pretty long detail that should wrap over multiple lines.", + ), + ` +Warning: A sourceless warning + +It has no source references but it does +have a pretty long detail that should +wrap over multiple lines. +`, + }, + "error with source code subject": { + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Bad bad bad", + Detail: "Whatever shall we do?", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 6, Byte: 5}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + }, + }, + ` +Error: Bad bad bad + + on test.tf line 1: + 1: test source code + +Whatever shall we do? +`, + }, + "error with source code subject and known expression": { + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Bad bad bad", + Detail: "Whatever shall we do?", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 6, Byte: 5}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + }, + Expression: hcltest.MockExprTraversal(hcl.Traversal{ + hcl.TraverseRoot{Name: "boop"}, + hcl.TraverseAttr{Name: "beep"}, + }), + EvalContext: &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "boop": cty.ObjectVal(map[string]cty.Value{ + "beep": cty.StringVal("blah"), + }), + }, + }, + }, + ` +Error: Bad bad bad + + on test.tf line 1: + 1: test source code + ├──────────────── + │ boop.beep is "blah" + +Whatever shall we do? +`, + }, + "error with source code subject and expression referring to sensitive value": { + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Bad bad bad", + Detail: "Whatever shall we do?", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 6, Byte: 5}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + }, + Expression: hcltest.MockExprTraversal(hcl.Traversal{ + hcl.TraverseRoot{Name: "boop"}, + hcl.TraverseAttr{Name: "beep"}, + }), + EvalContext: &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "boop": cty.ObjectVal(map[string]cty.Value{ + "beep": cty.StringVal("blah").Mark("sensitive"), + }), + }, + }, + }, + ` +Error: Bad bad bad + + on test.tf line 1: + 1: test source code + ├──────────────── + │ boop.beep has a sensitive value + +Whatever shall we do? +`, + }, + "error with source code subject and unknown string expression": { + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Bad bad bad", + Detail: "Whatever shall we do?", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 6, Byte: 5}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + }, + Expression: hcltest.MockExprTraversal(hcl.Traversal{ + hcl.TraverseRoot{Name: "boop"}, + hcl.TraverseAttr{Name: "beep"}, + }), + EvalContext: &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "boop": cty.ObjectVal(map[string]cty.Value{ + "beep": cty.UnknownVal(cty.String), + }), + }, + }, + }, + ` +Error: Bad bad bad + + on test.tf line 1: + 1: test source code + ├──────────────── + │ boop.beep is a string, known only after apply + +Whatever shall we do? +`, + }, + "error with source code subject and unknown expression of unknown type": { + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Bad bad bad", + Detail: "Whatever shall we do?", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 6, Byte: 5}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + }, + Expression: hcltest.MockExprTraversal(hcl.Traversal{ + hcl.TraverseRoot{Name: "boop"}, + hcl.TraverseAttr{Name: "beep"}, + }), + EvalContext: &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "boop": cty.ObjectVal(map[string]cty.Value{ + "beep": cty.UnknownVal(cty.DynamicPseudoType), + }), + }, + }, + }, + ` +Error: Bad bad bad + + on test.tf line 1: + 1: test source code + ├──────────────── + │ boop.beep will be known only after apply + +Whatever shall we do? +`, + }, + } + + sources := map[string][]byte{ + "test.tf": []byte(`test source code`), + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var diags tfdiags.Diagnostics + diags = diags.Append(test.Diag) // to normalize it into a tfdiag.Diagnostic + diag := diags[0] + got := strings.TrimSpace(DiagnosticPlain(diag, sources, 40)) + want := strings.TrimSpace(test.Want) + if got != want { + t.Errorf("wrong result\ngot:\n%s\n\nwant:\n%s\n\n", got, want) + } + }) + } +} + func TestDiagnosticWarningsCompact(t *testing.T) { var diags tfdiags.Diagnostics diags = diags.Append(tfdiags.SimpleWarning("foo")) @@ -390,6 +596,49 @@ func TestDiagnostic_emptyOverlapHighlightContext(t *testing.T) { } } +func TestDiagnosticPlain_emptyOverlapHighlightContext(t *testing.T) { + var diags tfdiags.Diagnostics + + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Some error", + Detail: "...", + Subject: &hcl.Range{ + Filename: "source.tf", + Start: hcl.Pos{Line: 3, Column: 10, Byte: 38}, + End: hcl.Pos{Line: 4, Column: 1, Byte: 39}, + }, + Context: &hcl.Range{ + Filename: "source.tf", + Start: hcl.Pos{Line: 2, Column: 13, Byte: 27}, + End: hcl.Pos{Line: 4, Column: 1, Byte: 39}, + }, + }) + sources := map[string][]byte{ + "source.tf": []byte(`variable "x" { + default = { + "foo" + } +`), + } + + expected := ` +Error: Some error + + on source.tf line 3, in variable "x": + 2: default = { + 3: "foo" + 4: } + +... +` + output := DiagnosticPlain(diags[0], sources, 80) + + if output != expected { + t.Fatalf("unexpected output: got:\n%s\nwant\n%s\n", output, expected) + } +} + func TestDiagnostic_wrapDetailIncludingCommand(t *testing.T) { var diags tfdiags.Diagnostics @@ -422,3 +671,31 @@ func TestDiagnostic_wrapDetailIncludingCommand(t *testing.T) { t.Fatalf("unexpected output: got:\n%s\nwant\n%s\n", output, expected) } } + +func TestDiagnosticPlain_wrapDetailIncludingCommand(t *testing.T) { + var diags tfdiags.Diagnostics + + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Everything went wrong", + Detail: "This is a very long sentence about whatever went wrong which is supposed to wrap onto multiple lines. Thank-you very much for listening.\n\nTo fix this, run this very long command:\n terraform read-my-mind -please -thanks -but-do-not-wrap-this-line-because-it-is-prefixed-with-spaces\n\nHere is a coda which is also long enough to wrap and so it should eventually make it onto multiple lines. THE END", + }) + + expected := ` +Error: Everything went wrong + +This is a very long sentence about whatever went wrong which is supposed to +wrap onto multiple lines. Thank-you very much for listening. + +To fix this, run this very long command: + terraform read-my-mind -please -thanks -but-do-not-wrap-this-line-because-it-is-prefixed-with-spaces + +Here is a coda which is also long enough to wrap and so it should +eventually make it onto multiple lines. THE END +` + output := DiagnosticPlain(diags[0], nil, 76) + + if output != expected { + t.Fatalf("unexpected output: got:\n%s\nwant\n%s\n", output, expected) + } +} diff --git a/command/format/state_test.go b/command/format/state_test.go index 103b4bc2dc..2fd11ce264 100644 --- a/command/format/state_test.go +++ b/command/format/state_test.go @@ -9,15 +9,9 @@ import ( "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" - "github.com/mitchellh/colorstring" "github.com/zclconf/go-cty/cty" ) -var disabledColorize = &colorstring.Colorize{ - Colors: colorstring.DefaultColors, - Disable: true, -} - func TestState(t *testing.T) { tests := []struct { State *StateOpts diff --git a/command/meta.go b/command/meta.go index 81c3e298c8..45498615b2 100644 --- a/command/meta.go +++ b/command/meta.go @@ -656,14 +656,20 @@ func (m *Meta) showDiagnostics(vals ...interface{}) { } for _, diag := range diags { - msg := format.Diagnostic(diag, m.configSources(), m.Colorize(), outputWidth) + var msg string + if m.Color { + msg = format.Diagnostic(diag, m.configSources(), m.Colorize(), outputWidth) + } else { + msg = format.DiagnosticPlain(diag, m.configSources(), outputWidth) + } + switch diag.Severity() { case tfdiags.Error: - m.Ui.Error(strings.TrimSpace(msg)) + m.Ui.Error(msg) case tfdiags.Warning: - m.Ui.Warn(strings.TrimSpace(msg)) + m.Ui.Warn(msg) default: - m.Ui.Output(strings.TrimSpace(msg)) + m.Ui.Output(msg) } } } diff --git a/command/state_mv_test.go b/command/state_mv_test.go index d7187fb52f..0b1ca78858 100644 --- a/command/state_mv_test.go +++ b/command/state_mv_test.go @@ -4,16 +4,21 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/states" ) +var disabledColorize = &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, +} + func TestStateMv(t *testing.T) { state := states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( @@ -270,7 +275,8 @@ func TestStateMv_resourceToInstanceErr(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) + ui := cli.NewMockUi() + c := &StateMvCommand{ StateMeta{ Meta: Meta{ @@ -290,19 +296,88 @@ func TestStateMv_resourceToInstanceErr(t *testing.T) { t.Fatalf("expected error output, got:\n%s", ui.OutputWriter.String()) } - expectedErr := `╷ -│ Error: Invalid target address -│ -│ Cannot move test_instance.foo to test_instance.bar[0]: the source is a -│ whole resource (not a resource instance) so the target must also be a whole -│ resource. -╵ + expectedErr := ` +Error: Invalid target address + +Cannot move test_instance.foo to test_instance.bar[0]: the source is a whole +resource (not a resource instance) so the target must also be a whole +resource. + ` errOutput := ui.ErrorWriter.String() if errOutput != expectedErr { t.Errorf("wrong output\n%s", cmp.Diff(errOutput, expectedErr)) } } + +func TestStateMv_resourceToInstanceErrInAutomation(t *testing.T) { + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"bar","foo":"value","bar":"value"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + s.SetResourceProvider( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "bar", + }.Absolute(addrs.RootModuleInstance), + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + statePath := testStateFile(t, state) + + p := testProvider() + ui := new(cli.MockUi) + c := &StateMvCommand{ + StateMeta{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: ui, + RunningInAutomation: true, + }, + }, + } + + args := []string{ + "-state", statePath, + "test_instance.foo", + "test_instance.bar[0]", + } + + if code := c.Run(args); code == 0 { + t.Fatalf("expected error output, got:\n%s", ui.OutputWriter.String()) + } + + expectedErr := ` +Error: Invalid target address + +Cannot move test_instance.foo to test_instance.bar[0]: the source is a whole +resource (not a resource instance) so the target must also be a whole +resource. + +` + errOutput := ui.ErrorWriter.String() + if errOutput != expectedErr { + t.Errorf("Unexpected diff.\ngot:\n%s\nwant:\n%s\n", errOutput, expectedErr) + t.Errorf("%s", cmp.Diff(errOutput, expectedErr)) + } +} + func TestStateMv_instanceToResource(t *testing.T) { state := states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( @@ -502,13 +577,14 @@ func TestStateMv_differentResourceTypes(t *testing.T) { t.Fatalf("expected error output, got:\n%s", ui.OutputWriter.String()) } - gotErr := strings.TrimSpace(ui.ErrorWriter.String()) - wantErr := strings.TrimSpace(`╷ -│ Error: Invalid state move request -│ -│ Cannot move test_instance.foo to test_network.bar: resource types don't -│ match. -╵`) + gotErr := ui.ErrorWriter.String() + wantErr := ` +Error: Invalid state move request + +Cannot move test_instance.foo to test_network.bar: resource types don't +match. + +` if gotErr != wantErr { t.Fatalf("expected initialization error\ngot:\n%s\n\nwant:%s", gotErr, wantErr) } diff --git a/command/taint_test.go b/command/taint_test.go index f6d21147a5..2e91a88b72 100644 --- a/command/taint_test.go +++ b/command/taint_test.go @@ -361,12 +361,12 @@ func TestTaint_missingAllow(t *testing.T) { // Check for the warning actual := strings.TrimSpace(ui.ErrorWriter.String()) - expected := strings.TrimSpace(`╷ -│ Warning: No such resource instance -│ -│ Resource instance test_instance.bar was not found, but this is not an error -│ because -allow-missing was set. -╵ + expected := strings.TrimSpace(` +Warning: No such resource instance + +Resource instance test_instance.bar was not found, but this is not an error +because -allow-missing was set. + `) if diff := cmp.Diff(expected, actual); diff != "" { t.Fatalf("wrong output\n%s", diff) diff --git a/command/untaint_test.go b/command/untaint_test.go index 3f78ded791..b1b365fdba 100644 --- a/command/untaint_test.go +++ b/command/untaint_test.go @@ -389,12 +389,12 @@ func TestUntaint_missingAllow(t *testing.T) { // Check for the warning actual := strings.TrimSpace(ui.ErrorWriter.String()) - expected := strings.TrimSpace(`╷ -│ Warning: No such resource instance -│ -│ Resource instance test_instance.bar was not found, but this is not an error -│ because -allow-missing was set. -╵ + expected := strings.TrimSpace(` +Warning: No such resource instance + +Resource instance test_instance.bar was not found, but this is not an error +because -allow-missing was set. + `) if diff := cmp.Diff(expected, actual); diff != "" { t.Fatalf("wrong output\n%s", diff)