mirror of
https://github.com/opentffoundation/opentf.git
synced 2026-03-20 22:01:25 -04:00
We don't typically just broadly run automatic rewriting tools like "go fix" across our codebase because that tends to cause annoying and unnecessary merge conflicts when we're backporting to earlier release branches. But all of the files in this commit were changed in some non-trivial way already during the OpenTofu v1.11 development period anyway, and so the likelyhood we'd be able to successfully backport from them is reduced and therefore this seems like a good opportunity to do some focused modernization using "go fix". My rules for what to include or not are admittedly quite "vibes-based", but the general idea was: - Focusing on files under the "command" directory only, because that's already been an area of intentional refactoring during this development period. - If the existing diff in a file is already significantly larger than the changes the fixer proposed to make, or if the fixer is proposing to change a line that was already changed in this development period. - More willing to include "_test.go" files than non-test files, even if they hadn't changed as much already, just because backports from test files for bug fixes tend to be entirely new test cases more than they are modifications to existing test cases, and so the risk of conflicts is lower there. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
501 lines
14 KiB
Go
501 lines
14 KiB
Go
// Copyright (c) The OpenTofu Authors
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
// Copyright (c) 2023 HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package views
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
"github.com/opentofu/opentofu/internal/addrs"
|
|
"github.com/opentofu/opentofu/internal/command/jsonentities"
|
|
viewsjson "github.com/opentofu/opentofu/internal/command/views/json"
|
|
"github.com/opentofu/opentofu/internal/plans"
|
|
"github.com/opentofu/opentofu/internal/terminal"
|
|
"github.com/opentofu/opentofu/internal/tfdiags"
|
|
tfversion "github.com/opentofu/opentofu/version"
|
|
)
|
|
|
|
// Calling NewJSONView should also always output a version message, which is a
|
|
// convenient way to test that NewJSONView works.
|
|
func TestNewJSONView(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
NewJSONView(NewView(streams), nil)
|
|
|
|
version := tfversion.String()
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": fmt.Sprintf("OpenTofu %s", version),
|
|
"@module": "tofu.ui",
|
|
"type": "version",
|
|
"tofu": version,
|
|
"ui": JSON_UI_VERSION,
|
|
},
|
|
}
|
|
|
|
testJSONViewOutputEqualsFull(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_Log(t *testing.T) {
|
|
testCases := []struct {
|
|
caseName string
|
|
input string
|
|
want []map[string]any
|
|
}{
|
|
{
|
|
"log with regular character",
|
|
"hello, world",
|
|
[]map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": "hello, world",
|
|
"@module": "tofu.ui",
|
|
"type": "log",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"log with special character",
|
|
"hello, special char, <>&",
|
|
[]map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": "hello, special char, <>&",
|
|
"@module": "tofu.ui",
|
|
"type": "log",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.caseName, func(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
jv.Log(tc.input)
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// This test covers only the basics of JSON diagnostic rendering, as more
|
|
// complex diagnostics are tested elsewhere.
|
|
func TestJSONView_Diagnostics(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
var diags tfdiags.Diagnostics
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Warning,
|
|
`Improper use of "less"`,
|
|
`You probably mean "10 buckets or fewer"`,
|
|
))
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Unusually stripey cat detected",
|
|
"Are you sure this random_pet isn't a cheetah?",
|
|
))
|
|
|
|
jv.Diagnostics(diags)
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "warn",
|
|
"@message": `Warning: Improper use of "less"`,
|
|
"@module": "tofu.ui",
|
|
"type": "diagnostic",
|
|
"diagnostic": map[string]any{
|
|
"severity": "warning",
|
|
"summary": `Improper use of "less"`,
|
|
"detail": `You probably mean "10 buckets or fewer"`,
|
|
},
|
|
},
|
|
{
|
|
"@level": "error",
|
|
"@message": "Error: Unusually stripey cat detected",
|
|
"@module": "tofu.ui",
|
|
"type": "diagnostic",
|
|
"diagnostic": map[string]any{
|
|
"severity": "error",
|
|
"summary": "Unusually stripey cat detected",
|
|
"detail": "Are you sure this random_pet isn't a cheetah?",
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_DiagnosticsWithMetadata(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
var diags tfdiags.Diagnostics
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Warning,
|
|
`Improper use of "less"`,
|
|
`You probably mean "10 buckets or fewer"`,
|
|
))
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Unusually stripey cat detected",
|
|
"Are you sure this random_pet isn't a cheetah?",
|
|
))
|
|
|
|
jv.Diagnostics(diags, "@meta", "extra_info")
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "warn",
|
|
"@message": `Warning: Improper use of "less"`,
|
|
"@module": "tofu.ui",
|
|
"type": "diagnostic",
|
|
"diagnostic": map[string]any{
|
|
"severity": "warning",
|
|
"summary": `Improper use of "less"`,
|
|
"detail": `You probably mean "10 buckets or fewer"`,
|
|
},
|
|
"@meta": "extra_info",
|
|
},
|
|
{
|
|
"@level": "error",
|
|
"@message": "Error: Unusually stripey cat detected",
|
|
"@module": "tofu.ui",
|
|
"type": "diagnostic",
|
|
"diagnostic": map[string]any{
|
|
"severity": "error",
|
|
"summary": "Unusually stripey cat detected",
|
|
"detail": "Are you sure this random_pet isn't a cheetah?",
|
|
},
|
|
"@meta": "extra_info",
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_PlannedChange(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
foo, diags := addrs.ParseModuleInstanceStr("module.foo")
|
|
if len(diags) > 0 {
|
|
t.Fatal(diags.Err())
|
|
}
|
|
managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"}
|
|
cs := &plans.ResourceInstanceChangeSrc{
|
|
Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo),
|
|
PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo),
|
|
ChangeSrc: plans.ChangeSrc{
|
|
Action: plans.Create,
|
|
},
|
|
}
|
|
jv.PlannedChange(jsonentities.NewResourceInstanceChange(cs))
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": `module.foo.test_instance.bar["boop"]: Plan to create`,
|
|
"@module": "tofu.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]any{
|
|
"action": "create",
|
|
"resource": map[string]any{
|
|
"addr": `module.foo.test_instance.bar["boop"]`,
|
|
"implied_provider": "test",
|
|
"module": "module.foo",
|
|
"resource": `test_instance.bar["boop"]`,
|
|
"resource_key": "boop",
|
|
"resource_name": "bar",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_ResourceDrift(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
foo, diags := addrs.ParseModuleInstanceStr("module.foo")
|
|
if len(diags) > 0 {
|
|
t.Fatal(diags.Err())
|
|
}
|
|
managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"}
|
|
cs := &plans.ResourceInstanceChangeSrc{
|
|
Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo),
|
|
PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo),
|
|
ChangeSrc: plans.ChangeSrc{
|
|
Action: plans.Update,
|
|
},
|
|
}
|
|
jv.ResourceDrift(jsonentities.NewResourceInstanceChange(cs))
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": `module.foo.test_instance.bar["boop"]: Drift detected (update)`,
|
|
"@module": "tofu.ui",
|
|
"type": "resource_drift",
|
|
"change": map[string]any{
|
|
"action": "update",
|
|
"resource": map[string]any{
|
|
"addr": `module.foo.test_instance.bar["boop"]`,
|
|
"implied_provider": "test",
|
|
"module": "module.foo",
|
|
"resource": `test_instance.bar["boop"]`,
|
|
"resource_key": "boop",
|
|
"resource_name": "bar",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_ChangeSummary(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
jv.ChangeSummary(&viewsjson.ChangeSummary{
|
|
Add: 1,
|
|
Change: 2,
|
|
Remove: 3,
|
|
Operation: viewsjson.OperationApplied,
|
|
})
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed.",
|
|
"@module": "tofu.ui",
|
|
"type": "change_summary",
|
|
"changes": map[string]any{
|
|
"add": float64(1),
|
|
"import": float64(0),
|
|
"change": float64(2),
|
|
"remove": float64(3),
|
|
"forget": float64(0),
|
|
"operation": "apply",
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_ChangeSummaryWithImport(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
jv.ChangeSummary(&viewsjson.ChangeSummary{
|
|
Add: 1,
|
|
Change: 2,
|
|
Remove: 3,
|
|
Import: 1,
|
|
Operation: viewsjson.OperationApplied,
|
|
})
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Apply complete! Resources: 1 imported, 1 added, 2 changed, 3 destroyed.",
|
|
"@module": "tofu.ui",
|
|
"type": "change_summary",
|
|
"changes": map[string]any{
|
|
"add": float64(1),
|
|
"change": float64(2),
|
|
"remove": float64(3),
|
|
"import": float64(1),
|
|
"forget": float64(0),
|
|
"operation": "apply",
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_ChangeSummaryWithForget(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
jv.ChangeSummary(&viewsjson.ChangeSummary{
|
|
Add: 1,
|
|
Change: 2,
|
|
Remove: 3,
|
|
Forget: 1,
|
|
Operation: viewsjson.OperationApplied,
|
|
})
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed, 1 forgotten.",
|
|
"@module": "tofu.ui",
|
|
"type": "change_summary",
|
|
"changes": map[string]any{
|
|
"add": float64(1),
|
|
"change": float64(2),
|
|
"remove": float64(3),
|
|
"import": float64(0),
|
|
"forget": float64(1),
|
|
"operation": "apply",
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_Hook(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
foo, diags := addrs.ParseModuleInstanceStr("module.foo")
|
|
if len(diags) > 0 {
|
|
t.Fatal(diags.Err())
|
|
}
|
|
managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"}
|
|
addr := managed.Instance(addrs.StringKey("boop")).Absolute(foo)
|
|
hook := viewsjson.NewApplyComplete(addr, plans.Create, "id", "boop-beep", 34*time.Second)
|
|
|
|
jv.Hook(hook)
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": `module.foo.test_instance.bar["boop"]: Creation complete after 34s [id=boop-beep]`,
|
|
"@module": "tofu.ui",
|
|
"type": "apply_complete",
|
|
"hook": map[string]any{
|
|
"resource": map[string]any{
|
|
"addr": `module.foo.test_instance.bar["boop"]`,
|
|
"implied_provider": "test",
|
|
"module": "module.foo",
|
|
"resource": `test_instance.bar["boop"]`,
|
|
"resource_key": "boop",
|
|
"resource_name": "bar",
|
|
"resource_type": "test_instance",
|
|
},
|
|
"action": "create",
|
|
"id_key": "id",
|
|
"id_value": "boop-beep",
|
|
"elapsed_seconds": float64(34),
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestJSONView_Outputs(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
jv := NewJSONView(NewView(streams), nil)
|
|
|
|
jv.Outputs(jsonentities.Outputs{
|
|
"boop_count": {
|
|
Sensitive: false,
|
|
Value: json.RawMessage(`92`),
|
|
Type: json.RawMessage(`"number"`),
|
|
},
|
|
"password": {
|
|
Sensitive: true,
|
|
Value: json.RawMessage(`"horse-battery"`),
|
|
Type: json.RawMessage(`"string"`),
|
|
},
|
|
})
|
|
|
|
want := []map[string]any{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Outputs: 2",
|
|
"@module": "tofu.ui",
|
|
"type": "outputs",
|
|
"outputs": map[string]any{
|
|
"boop_count": map[string]any{
|
|
"sensitive": false,
|
|
"value": float64(92),
|
|
"type": "number",
|
|
},
|
|
"password": map[string]any{
|
|
"sensitive": true,
|
|
"value": "horse-battery",
|
|
"type": "string",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
// This helper function tests a possibly multi-line JSONView output string
|
|
// against a slice of structs representing the desired log messages. It
|
|
// verifies that the output of JSONView is in JSON log format, one message per
|
|
// line.
|
|
func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string]any, options ...cmp.Option) {
|
|
t.Helper()
|
|
|
|
// Remove final trailing newline
|
|
output = strings.TrimSuffix(output, "\n")
|
|
|
|
// Split log into lines, each of which should be a JSON log message
|
|
gotLines := strings.Split(output, "\n")
|
|
|
|
if len(gotLines) != len(want) {
|
|
t.Errorf("unexpected number of messages. got %d, want %d", len(gotLines), len(want))
|
|
}
|
|
|
|
// Unmarshal each line and compare to the expected value
|
|
for i := range gotLines {
|
|
var gotStruct map[string]any
|
|
if i >= len(want) {
|
|
t.Error("reached end of want messages too soon")
|
|
break
|
|
}
|
|
wantStruct := want[i]
|
|
// When the json content generated is empty, there will be an empty one liner that can be matched
|
|
// by a "want" slice with one empty element
|
|
if len(gotLines[i]) == 0 && len(wantStruct) == 0 {
|
|
t.Logf("json output empty and that matches the requirements")
|
|
continue
|
|
}
|
|
if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if timestamp, ok := gotStruct["@timestamp"]; !ok {
|
|
t.Errorf("message has no timestamp: %#v", gotStruct)
|
|
} else {
|
|
// Remove the timestamp value from the struct to allow comparison
|
|
delete(gotStruct, "@timestamp")
|
|
|
|
// Verify the timestamp format
|
|
if _, err := time.Parse("2006-01-02T15:04:05.000000Z07:00", timestamp.(string)); err != nil {
|
|
t.Errorf("error parsing timestamp on line %d: %s", i, err)
|
|
}
|
|
}
|
|
|
|
if !cmp.Equal(wantStruct, gotStruct, options...) {
|
|
t.Errorf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct))
|
|
}
|
|
}
|
|
}
|
|
|
|
// testJSONViewOutputEquals skips the first line of output, since it ought to
|
|
// be a version message that we don't care about for most of our tests.
|
|
func testJSONViewOutputEquals(t *testing.T, output string, want []map[string]any, options ...cmp.Option) {
|
|
t.Helper()
|
|
|
|
// Remove up to the first newline
|
|
index := strings.Index(output, "\n")
|
|
if index >= 0 {
|
|
output = output[index+1:]
|
|
}
|
|
testJSONViewOutputEqualsFull(t, output, want, options...)
|
|
}
|