cli: Migrate show command to use command arguments and views

This commit is contained in:
Krista LaFentres
2022-01-10 17:16:12 -06:00
parent 8d1bced812
commit fea8f6cfa2
9 changed files with 871 additions and 281 deletions

View File

@@ -2,37 +2,95 @@ package views
import (
"fmt"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/command/jsonstate"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// FIXME: this is a temporary partial definition of the view for the show
// command, in place to allow access to the plan renderer which is now in the
// views package.
type Show interface {
Plan(plan *plans.Plan, schemas *terraform.Schemas)
// Display renders the plan, if it is available. If plan is nil, it renders the statefile.
Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int
// Diagnostics renders early diagnostics, resulting from argument parsing.
Diagnostics(diags tfdiags.Diagnostics)
}
// FIXME: the show view should support both human and JSON types. This code is
// currently only used to render the plan in human-readable UI, so does not yet
// support JSON.
func NewShow(vt arguments.ViewType, view *View) Show {
switch vt {
case arguments.ViewJSON:
return &ShowJSON{view: view}
case arguments.ViewHuman:
return &ShowHuman{View: *view}
return &ShowHuman{view: view}
default:
panic(fmt.Sprintf("unknown view type %v", vt))
}
}
type ShowHuman struct {
View
view *View
}
var _ Show = (*ShowHuman)(nil)
func (v *ShowHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
renderPlan(plan, schemas, &v.View)
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
if plan != nil {
renderPlan(plan, schemas, v.view)
} else {
if stateFile == nil {
v.view.streams.Println("No state.")
return 0
}
v.view.streams.Println(format.State(&format.StateOpts{
State: stateFile.State,
Color: v.view.colorize,
Schemas: schemas,
}))
}
return 0
}
func (v *ShowHuman) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}
type ShowJSON struct {
view *View
}
var _ Show = (*ShowJSON)(nil)
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
if plan != nil {
jsonPlan, err := jsonplan.Marshal(config, plan, stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonPlan))
} else {
// It is possible that there is neither state nor a plan.
// That's ok, we'll just return an empty object.
jsonState, err := jsonstate.Marshal(stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal state to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonState))
}
return 0
}
// Diagnostics should only be called if show cannot be executed.
// In this case, we choose to render human-readable diagnostic output,
// primarily for backwards compatibility.
func (v *ShowJSON) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}

View File

@@ -0,0 +1,184 @@
package views
import (
"encoding/json"
"strings"
"testing"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/zclconf/go-cty/cty"
)
func TestShowHuman(t *testing.T) {
testCases := map[string]struct {
plan *plans.Plan
stateFile *statefile.File
schemas *terraform.Schemas
wantExact bool
wantString string
}{
"plan file": {
testPlan(t),
nil,
testSchemas(),
false,
"# test_resource.foo will be created",
},
"statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: testState(),
},
testSchemas(),
false,
"# test_resource.foo:",
},
"empty statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: states.NewState(),
},
testSchemas(),
true,
"\n",
},
"nothing": {
nil,
nil,
nil,
true,
"No state.\n",
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
view.Configure(&arguments.View{NoColor: true})
v := NewShow(arguments.ViewHuman, view)
code := v.Display(nil, testCase.plan, testCase.stateFile, testCase.schemas)
if code != 0 {
t.Errorf("expected 0 return code, got %d", code)
}
output := done(t)
got := output.Stdout()
want := testCase.wantString
if (testCase.wantExact && got != want) || (!testCase.wantExact && !strings.Contains(got, want)) {
t.Fatalf("unexpected output\ngot: %s\nwant: %s", got, want)
}
})
}
}
func TestShowJSON(t *testing.T) {
testCases := map[string]struct {
plan *plans.Plan
stateFile *statefile.File
}{
"plan file": {
testPlan(t),
nil,
},
"statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: testState(),
},
},
"empty statefile": {
nil,
&statefile.File{
Serial: 0,
Lineage: "fake-for-testing",
State: states.NewState(),
},
},
"nothing": {
nil,
nil,
},
}
config, _, configCleanup := initwd.MustLoadConfigForTests(t, "./testdata/show")
defer configCleanup()
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
view.Configure(&arguments.View{NoColor: true})
v := NewShow(arguments.ViewJSON, view)
schemas := &terraform.Schemas{
Providers: map[addrs.Provider]*terraform.ProviderSchema{
addrs.NewDefaultProvider("test"): {
ResourceTypes: map[string]*configschema.Block{
"test_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"foo": {Type: cty.String, Optional: true},
},
},
},
},
},
}
code := v.Display(config, testCase.plan, testCase.stateFile, schemas)
if code != 0 {
t.Errorf("expected 0 return code, got %d", code)
}
// Make sure the result looks like JSON; we comprehensively test
// the structure of this output in the command package tests.
var result map[string]interface{}
got := done(t).All()
t.Logf("output: %s", got)
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatal(err)
}
})
}
}
// testState returns a test State structure.
func testState() *states.State {
return states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"bar","foo":"value"}`),
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
)
// DeepCopy is used here to ensure our synthetic state matches exactly
// with a state that will have been copied during the command
// operation, and all fields have been copied correctly.
}).DeepCopy()
}

View File

@@ -0,0 +1,3 @@
resource "test_resource" "foo" {
foo = "value"
}