618 lines
18 KiB
Go
618 lines
18 KiB
Go
package views
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/terraform/internal/addrs"
|
|
"github.com/hashicorp/terraform/internal/command/arguments"
|
|
"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"
|
|
)
|
|
|
|
func TestOperation_stopping(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
v.Stopping()
|
|
|
|
if got, want := done(t).Stdout(), "Stopping operation...\n"; got != want {
|
|
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOperation_cancelled(t *testing.T) {
|
|
testCases := map[string]struct {
|
|
planMode plans.Mode
|
|
want string
|
|
}{
|
|
"apply": {
|
|
planMode: plans.NormalMode,
|
|
want: "Apply cancelled.\n",
|
|
},
|
|
"destroy": {
|
|
planMode: plans.DestroyMode,
|
|
want: "Destroy cancelled.\n",
|
|
},
|
|
}
|
|
for name, tc := range testCases {
|
|
t.Run(name, func(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
v.Cancelled(tc.planMode)
|
|
|
|
if got, want := done(t).Stdout(), tc.want; got != want {
|
|
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOperation_emergencyDumpState(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
stateFile := statefile.New(nil, "foo", 1)
|
|
|
|
err := v.EmergencyDumpState(stateFile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error dumping state: %s", err)
|
|
}
|
|
|
|
// Check that the result (on stderr) looks like JSON state
|
|
raw := done(t).Stderr()
|
|
var state map[string]interface{}
|
|
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
|
t.Fatalf("unexpected error parsing dumped state: %s\nraw:\n%s", err, raw)
|
|
}
|
|
}
|
|
|
|
func TestOperation_planNoChanges(t *testing.T) {
|
|
|
|
tests := map[string]struct {
|
|
plan func(schemas *terraform.Schemas) *plans.Plan
|
|
wantText string
|
|
}{
|
|
"nothing at all in normal mode": {
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
return &plans.Plan{
|
|
UIMode: plans.NormalMode,
|
|
Changes: plans.NewChanges(),
|
|
PrevRunState: states.NewState(),
|
|
PriorState: states.NewState(),
|
|
}
|
|
},
|
|
"no differences, so no changes are needed.",
|
|
},
|
|
"nothing at all in refresh-only mode": {
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
return &plans.Plan{
|
|
UIMode: plans.RefreshOnlyMode,
|
|
Changes: plans.NewChanges(),
|
|
PrevRunState: states.NewState(),
|
|
PriorState: states.NewState(),
|
|
}
|
|
},
|
|
"Terraform has checked that the real remote objects still match",
|
|
},
|
|
"nothing at all in destroy mode": {
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
return &plans.Plan{
|
|
UIMode: plans.DestroyMode,
|
|
Changes: plans.NewChanges(),
|
|
PrevRunState: states.NewState(),
|
|
PriorState: states.NewState(),
|
|
}
|
|
},
|
|
"No objects need to be destroyed.",
|
|
},
|
|
"drift detected in normal mode": {
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
return &plans.Plan{
|
|
UIMode: plans.NormalMode,
|
|
Changes: plans.NewChanges(),
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
state.SetResourceInstanceCurrent(
|
|
addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "something",
|
|
Name: "somewhere",
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
&states.ResourceInstanceObjectSrc{
|
|
Status: states.ObjectReady,
|
|
AttrsJSON: []byte(`{}`),
|
|
},
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")),
|
|
)
|
|
}),
|
|
PriorState: states.NewState(),
|
|
}
|
|
},
|
|
"to update the Terraform state to match, create and apply a refresh-only plan",
|
|
},
|
|
"drift detected in refresh-only mode": {
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
return &plans.Plan{
|
|
UIMode: plans.RefreshOnlyMode,
|
|
Changes: plans.NewChanges(),
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
state.SetResourceInstanceCurrent(
|
|
addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "something",
|
|
Name: "somewhere",
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
&states.ResourceInstanceObjectSrc{
|
|
Status: states.ObjectReady,
|
|
AttrsJSON: []byte(`{}`),
|
|
},
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")),
|
|
)
|
|
}),
|
|
PriorState: states.NewState(),
|
|
}
|
|
},
|
|
"If you were expecting these changes then you can apply this plan",
|
|
},
|
|
"drift detected in destroy mode": {
|
|
func(schemas *terraform.Schemas) *plans.Plan {
|
|
return &plans.Plan{
|
|
UIMode: plans.DestroyMode,
|
|
Changes: plans.NewChanges(),
|
|
PrevRunState: states.BuildState(func(state *states.SyncState) {
|
|
state.SetResourceInstanceCurrent(
|
|
addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "something",
|
|
Name: "somewhere",
|
|
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
|
&states.ResourceInstanceObjectSrc{
|
|
Status: states.ObjectReady,
|
|
AttrsJSON: []byte(`{}`),
|
|
},
|
|
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")),
|
|
)
|
|
}),
|
|
PriorState: states.NewState(),
|
|
}
|
|
},
|
|
"No objects need to be destroyed.",
|
|
},
|
|
}
|
|
|
|
schemas := testSchemas()
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
plan := test.plan(schemas)
|
|
v.Plan(plan, schemas)
|
|
got := done(t).Stdout()
|
|
if want := test.wantText; want != "" && !strings.Contains(got, want) {
|
|
t.Errorf("missing expected message\ngot:\n%s\n\nwant substring: %s", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOperation_plan(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, true, NewView(streams))
|
|
|
|
plan := testPlan(t)
|
|
schemas := testSchemas()
|
|
v.Plan(plan, schemas)
|
|
|
|
want := `
|
|
Terraform used the selected providers to generate the following execution
|
|
plan. Resource actions are indicated with the following symbols:
|
|
+ create
|
|
|
|
Terraform will perform the following actions:
|
|
|
|
# test_resource.foo will be created
|
|
+ resource "test_resource" "foo" {
|
|
+ foo = "bar"
|
|
+ id = (known after apply)
|
|
}
|
|
|
|
Plan: 1 to add, 0 to change, 0 to destroy.
|
|
`
|
|
|
|
if got := done(t).Stdout(); got != want {
|
|
t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOperation_planNextStep(t *testing.T) {
|
|
testCases := map[string]struct {
|
|
path string
|
|
want string
|
|
}{
|
|
"no state path": {
|
|
path: "",
|
|
want: "You didn't use the -out option",
|
|
},
|
|
"state path": {
|
|
path: "good plan.tfplan",
|
|
want: `terraform apply "good plan.tfplan"`,
|
|
},
|
|
}
|
|
for name, tc := range testCases {
|
|
t.Run(name, func(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
|
|
|
v.PlanNextStep(tc.path)
|
|
|
|
if got := done(t).Stdout(); !strings.Contains(got, tc.want) {
|
|
t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The in-automation state is on the view itself, so testing it separately is
|
|
// clearer.
|
|
func TestOperation_planNextStepInAutomation(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := NewOperation(arguments.ViewHuman, true, NewView(streams))
|
|
|
|
v.PlanNextStep("")
|
|
|
|
if got := done(t).Stdout(); got != "" {
|
|
t.Errorf("unexpected output\ngot: %q", got)
|
|
}
|
|
}
|
|
|
|
// Test all the trivial OperationJSON methods together. Y'know, for brevity.
|
|
// This test is not a realistic stream of messages.
|
|
func TestOperationJSON_logs(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
v.Cancelled(plans.NormalMode)
|
|
v.Cancelled(plans.DestroyMode)
|
|
v.Stopping()
|
|
v.Interrupted()
|
|
v.FatalInterrupt()
|
|
|
|
want := []map[string]interface{}{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Apply cancelled",
|
|
"@module": "terraform.ui",
|
|
"type": "log",
|
|
},
|
|
{
|
|
"@level": "info",
|
|
"@message": "Destroy cancelled",
|
|
"@module": "terraform.ui",
|
|
"type": "log",
|
|
},
|
|
{
|
|
"@level": "info",
|
|
"@message": "Stopping operation...",
|
|
"@module": "terraform.ui",
|
|
"type": "log",
|
|
},
|
|
{
|
|
"@level": "info",
|
|
"@message": interrupted,
|
|
"@module": "terraform.ui",
|
|
"type": "log",
|
|
},
|
|
{
|
|
"@level": "info",
|
|
"@message": fatalInterrupt,
|
|
"@module": "terraform.ui",
|
|
"type": "log",
|
|
},
|
|
}
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
// This is a fairly circular test, but it's such a rarely executed code path
|
|
// that I think it's probably still worth having. We're not testing against
|
|
// a fixed state JSON output because this test ought not fail just because
|
|
// we upgrade state format in the future.
|
|
func TestOperationJSON_emergencyDumpState(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
stateFile := statefile.New(nil, "foo", 1)
|
|
stateBuf := new(bytes.Buffer)
|
|
err := statefile.Write(stateFile, stateBuf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stateJSON map[string]interface{}
|
|
err = json.Unmarshal(stateBuf.Bytes(), &stateJSON)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = v.EmergencyDumpState(stateFile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error dumping state: %s", err)
|
|
}
|
|
|
|
want := []map[string]interface{}{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Emergency state dump",
|
|
"@module": "terraform.ui",
|
|
"type": "log",
|
|
"state": stateJSON,
|
|
},
|
|
}
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestOperationJSON_planNoChanges(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
plan := &plans.Plan{
|
|
Changes: plans.NewChanges(),
|
|
}
|
|
v.Plan(plan, nil)
|
|
|
|
want := []map[string]interface{}{
|
|
{
|
|
"@level": "info",
|
|
"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
|
|
"@module": "terraform.ui",
|
|
"type": "change_summary",
|
|
"changes": map[string]interface{}{
|
|
"operation": "plan",
|
|
"add": float64(0),
|
|
"change": float64(0),
|
|
"remove": float64(0),
|
|
},
|
|
},
|
|
}
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestOperationJSON_plan(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
root := addrs.RootModuleInstance
|
|
vpc, diags := addrs.ParseModuleInstanceStr("module.vpc")
|
|
if len(diags) > 0 {
|
|
t.Fatal(diags.Err())
|
|
}
|
|
boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"}
|
|
beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "beep"}
|
|
derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
|
|
|
|
plan := &plans.Plan{
|
|
Changes: &plans.Changes{
|
|
Resources: []*plans.ResourceInstanceChangeSrc{
|
|
{
|
|
Addr: boop.Instance(addrs.IntKey(0)).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.CreateThenDelete},
|
|
},
|
|
{
|
|
Addr: boop.Instance(addrs.IntKey(1)).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Create},
|
|
},
|
|
{
|
|
Addr: boop.Instance(addrs.IntKey(0)).Absolute(vpc),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
|
|
},
|
|
{
|
|
Addr: beep.Instance(addrs.NoKey).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate},
|
|
},
|
|
{
|
|
Addr: beep.Instance(addrs.NoKey).Absolute(vpc),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Update},
|
|
},
|
|
// Data source deletion should not show up in the logs
|
|
{
|
|
Addr: derp.Instance(addrs.NoKey).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
v.Plan(plan, nil)
|
|
|
|
want := []map[string]interface{}{
|
|
// Create-then-delete should result in replace
|
|
{
|
|
"@level": "info",
|
|
"@message": "test_instance.boop[0]: Plan to replace",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "replace",
|
|
"resource": map[string]interface{}{
|
|
"addr": `test_instance.boop[0]`,
|
|
"implied_provider": "test",
|
|
"module": "",
|
|
"resource": `test_instance.boop[0]`,
|
|
"resource_key": float64(0),
|
|
"resource_name": "boop",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
// Simple create
|
|
{
|
|
"@level": "info",
|
|
"@message": "test_instance.boop[1]: Plan to create",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "create",
|
|
"resource": map[string]interface{}{
|
|
"addr": `test_instance.boop[1]`,
|
|
"implied_provider": "test",
|
|
"module": "",
|
|
"resource": `test_instance.boop[1]`,
|
|
"resource_key": float64(1),
|
|
"resource_name": "boop",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
// Simple delete
|
|
{
|
|
"@level": "info",
|
|
"@message": "module.vpc.test_instance.boop[0]: Plan to delete",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "delete",
|
|
"resource": map[string]interface{}{
|
|
"addr": `module.vpc.test_instance.boop[0]`,
|
|
"implied_provider": "test",
|
|
"module": "module.vpc",
|
|
"resource": `test_instance.boop[0]`,
|
|
"resource_key": float64(0),
|
|
"resource_name": "boop",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
// Delete-then-create is also a replace
|
|
{
|
|
"@level": "info",
|
|
"@message": "test_instance.beep: Plan to replace",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "replace",
|
|
"resource": map[string]interface{}{
|
|
"addr": `test_instance.beep`,
|
|
"implied_provider": "test",
|
|
"module": "",
|
|
"resource": `test_instance.beep`,
|
|
"resource_key": nil,
|
|
"resource_name": "beep",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
// Simple update
|
|
{
|
|
"@level": "info",
|
|
"@message": "module.vpc.test_instance.beep: Plan to update",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "update",
|
|
"resource": map[string]interface{}{
|
|
"addr": `module.vpc.test_instance.beep`,
|
|
"implied_provider": "test",
|
|
"module": "module.vpc",
|
|
"resource": `test_instance.beep`,
|
|
"resource_key": nil,
|
|
"resource_name": "beep",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
// These counts are 3 add/1 change/3 destroy because the replace
|
|
// changes result in both add and destroy counts.
|
|
{
|
|
"@level": "info",
|
|
"@message": "Plan: 3 to add, 1 to change, 3 to destroy.",
|
|
"@module": "terraform.ui",
|
|
"type": "change_summary",
|
|
"changes": map[string]interface{}{
|
|
"operation": "plan",
|
|
"add": float64(3),
|
|
"change": float64(1),
|
|
"remove": float64(3),
|
|
},
|
|
},
|
|
}
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|
|
|
|
func TestOperationJSON_plannedChange(t *testing.T) {
|
|
streams, done := terminal.StreamsForTesting(t)
|
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
|
|
|
root := addrs.RootModuleInstance
|
|
boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"}
|
|
derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
|
|
|
|
// Replace requested by user
|
|
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
|
|
Addr: boop.Instance(addrs.IntKey(0)).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate},
|
|
ActionReason: plans.ResourceInstanceReplaceByRequest,
|
|
})
|
|
|
|
// Simple create
|
|
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
|
|
Addr: boop.Instance(addrs.IntKey(1)).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Create},
|
|
})
|
|
|
|
// Data source deletion
|
|
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
|
|
Addr: derp.Instance(addrs.NoKey).Absolute(root),
|
|
ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
|
|
})
|
|
|
|
// Expect only two messages, as the data source deletion should be a no-op
|
|
want := []map[string]interface{}{
|
|
{
|
|
"@level": "info",
|
|
"@message": "test_instance.boop[0]: Plan to replace",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "replace",
|
|
"reason": "requested",
|
|
"resource": map[string]interface{}{
|
|
"addr": `test_instance.boop[0]`,
|
|
"implied_provider": "test",
|
|
"module": "",
|
|
"resource": `test_instance.boop[0]`,
|
|
"resource_key": float64(0),
|
|
"resource_name": "boop",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"@level": "info",
|
|
"@message": "test_instance.boop[1]: Plan to create",
|
|
"@module": "terraform.ui",
|
|
"type": "planned_change",
|
|
"change": map[string]interface{}{
|
|
"action": "create",
|
|
"resource": map[string]interface{}{
|
|
"addr": `test_instance.boop[1]`,
|
|
"implied_provider": "test",
|
|
"module": "",
|
|
"resource": `test_instance.boop[1]`,
|
|
"resource_key": float64(1),
|
|
"resource_name": "boop",
|
|
"resource_type": "test_instance",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
testJSONViewOutputEquals(t, done(t).Stdout(), want)
|
|
}
|