diff --git a/tfexec/internal/e2etest/show_test.go b/tfexec/internal/e2etest/show_test.go index 77b458ac..078b3ee4 100644 --- a/tfexec/internal/e2etest/show_test.go +++ b/tfexec/internal/e2etest/show_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "reflect" + "runtime" "testing" "github.com/davecgh/go-spew/spew" @@ -11,6 +12,7 @@ import ( tfjson "github.com/hashicorp/terraform-json" "github.com/hashicorp/terraform-exec/tfexec" + "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" ) var ( @@ -104,3 +106,214 @@ func TestShow_versionMismatch(t *testing.T) { } }) } + +// Non-default state files cannot be migrated from 0.12 to 0.13, +// so we maintain one fixture per supported version. +// See github.com/hashicorp/terraform/25920 +func TestShowStateFile012(t *testing.T) { + runTestVersions(t, []string{testutil.Latest012}, "non_default_statefile_012", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + expected := tfjson.State{ + FormatVersion: "0.1", + TerraformVersion: "0.12.29", + Values: &tfjson.StateValues{ + RootModule: &tfjson.StateModule{ + Resources: []*tfjson.StateResource{{ + Address: "null_resource.foo", + AttributeValues: map[string]interface{}{ + "id": "3610244792381545397", + "triggers": nil, + }, + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: "null", + }}, + }, + }, + } + + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + actual, err := tf.ShowStateFile(context.Background(), "statefilefoo") + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, &expected) { + t.Fatalf("actual: %s\nexpected: %s", spew.Sdump(actual), spew.Sdump(expected)) + } + }) +} + +func TestShowStateFile013(t *testing.T) { + runTestVersions(t, []string{testutil.Latest013}, "non_default_statefile_013", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + expected := tfjson.State{ + FormatVersion: "0.1", + TerraformVersion: "0.13.0", + Values: &tfjson.StateValues{ + RootModule: &tfjson.StateModule{ + Resources: []*tfjson.StateResource{{ + Address: "null_resource.foo", + AttributeValues: map[string]interface{}{ + "id": "3610244792381545397", + "triggers": nil, + }, + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: "registry.terraform.io/hashicorp/null", + }}, + }, + }, + } + + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + actual, err := tf.ShowStateFile(context.Background(), "statefilefoo") + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, &expected) { + t.Fatalf("actual: %s\nexpected: %s", spew.Sdump(actual), spew.Sdump(expected)) + } + }) +} + +// Plan files cannot be transferred between different Terraform versions, +// so we maintain one fixture per supported version +func TestShowPlanFile012(t *testing.T) { + runTestVersions(t, []string{testutil.Latest012}, "non_default_planfile_012", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + // plan file fixture was created in Linux, and is + // not compatible with Windows + if runtime.GOOS == "windows" { + t.Skip("plan file created in 0.12 on Linux is not compatible with Windows") + } + + providerName := "null" + + expected := tfjson.Plan{ + FormatVersion: "0.1", + TerraformVersion: "0.12.29", + PlannedValues: &tfjson.StateValues{ + RootModule: &tfjson.StateModule{ + Resources: []*tfjson.StateResource{{ + Address: "null_resource.foo", + AttributeValues: map[string]interface{}{ + "triggers": nil, + }, + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: providerName, + }}, + }, + }, + ResourceChanges: []*tfjson.ResourceChange{{ + Address: "null_resource.foo", + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: providerName, + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionCreate}, + After: map[string]interface{}{"triggers": nil}, + AfterUnknown: map[string]interface{}{"id": (true)}, + }, + }}, + Config: &tfjson.Config{ + RootModule: &tfjson.ConfigModule{ + Resources: []*tfjson.ConfigResource{{ + Address: "null_resource.foo", + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderConfigKey: "null", + }}, + }, + }, + } + + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + actual, err := tf.ShowPlanFile(context.Background(), "planfilefoo") + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, &expected) { + t.Fatalf("actual: %s\nexpected: %s", spew.Sdump(actual), spew.Sdump(expected)) + } + }) +} + +func TestShowPlanFile013(t *testing.T) { + runTestVersions(t, []string{testutil.Latest013}, "non_default_planfile_013", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + providerName := "registry.terraform.io/hashicorp/null" + + expected := tfjson.Plan{ + FormatVersion: "0.1", + TerraformVersion: "0.13.0", + PlannedValues: &tfjson.StateValues{ + RootModule: &tfjson.StateModule{ + Resources: []*tfjson.StateResource{{ + Address: "null_resource.foo", + AttributeValues: map[string]interface{}{ + "triggers": nil, + }, + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: providerName, + }}, + }, + }, + ResourceChanges: []*tfjson.ResourceChange{{ + Address: "null_resource.foo", + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: providerName, + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionCreate}, + After: map[string]interface{}{"triggers": nil}, + AfterUnknown: map[string]interface{}{"id": true}, + }, + }}, + Config: &tfjson.Config{ + RootModule: &tfjson.ConfigModule{ + Resources: []*tfjson.ConfigResource{{ + Address: "null_resource.foo", + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderConfigKey: "null", + }}, + }, + }, + } + + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + actual, err := tf.ShowPlanFile(context.Background(), "planfilefoo") + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, &expected) { + t.Fatalf("actual: %s\nexpected: %s", spew.Sdump(actual), spew.Sdump(expected)) + } + }) +} diff --git a/tfexec/internal/e2etest/testdata/non_default_planfile_012/main.tf b/tfexec/internal/e2etest/testdata/non_default_planfile_012/main.tf new file mode 100644 index 00000000..fca4eb6d --- /dev/null +++ b/tfexec/internal/e2etest/testdata/non_default_planfile_012/main.tf @@ -0,0 +1,3 @@ +resource null_resource "foo" { +} + diff --git a/tfexec/internal/e2etest/testdata/non_default_planfile_012/planfilefoo b/tfexec/internal/e2etest/testdata/non_default_planfile_012/planfilefoo new file mode 100644 index 00000000..9da65dcf Binary files /dev/null and b/tfexec/internal/e2etest/testdata/non_default_planfile_012/planfilefoo differ diff --git a/tfexec/internal/e2etest/testdata/non_default_planfile_013/main.tf b/tfexec/internal/e2etest/testdata/non_default_planfile_013/main.tf new file mode 100644 index 00000000..fca4eb6d --- /dev/null +++ b/tfexec/internal/e2etest/testdata/non_default_planfile_013/main.tf @@ -0,0 +1,3 @@ +resource null_resource "foo" { +} + diff --git a/tfexec/internal/e2etest/testdata/non_default_planfile_013/planfilefoo b/tfexec/internal/e2etest/testdata/non_default_planfile_013/planfilefoo new file mode 100644 index 00000000..b8658a75 Binary files /dev/null and b/tfexec/internal/e2etest/testdata/non_default_planfile_013/planfilefoo differ diff --git a/tfexec/internal/e2etest/testdata/non_default_statefile_012/main.tf b/tfexec/internal/e2etest/testdata/non_default_statefile_012/main.tf new file mode 100644 index 00000000..fca4eb6d --- /dev/null +++ b/tfexec/internal/e2etest/testdata/non_default_statefile_012/main.tf @@ -0,0 +1,3 @@ +resource null_resource "foo" { +} + diff --git a/tfexec/internal/e2etest/testdata/non_default_statefile_012/statefilefoo b/tfexec/internal/e2etest/testdata/non_default_statefile_012/statefilefoo new file mode 100644 index 00000000..48785900 --- /dev/null +++ b/tfexec/internal/e2etest/testdata/non_default_statefile_012/statefilefoo @@ -0,0 +1,25 @@ +{ + "version": 4, + "terraform_version": "0.12.29", + "serial": 1, + "lineage": "b69e96b9-250f-2004-c603-1b11dc3459c1", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider": "provider.null", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "3610244792381545397", + "triggers": null + }, + "private": "bnVsbA==" + } + ] + } + ] +} diff --git a/tfexec/internal/e2etest/testdata/non_default_statefile_013/main.tf b/tfexec/internal/e2etest/testdata/non_default_statefile_013/main.tf new file mode 100644 index 00000000..fca4eb6d --- /dev/null +++ b/tfexec/internal/e2etest/testdata/non_default_statefile_013/main.tf @@ -0,0 +1,3 @@ +resource null_resource "foo" { +} + diff --git a/tfexec/internal/e2etest/testdata/non_default_statefile_013/statefilefoo b/tfexec/internal/e2etest/testdata/non_default_statefile_013/statefilefoo new file mode 100644 index 00000000..2af868b8 --- /dev/null +++ b/tfexec/internal/e2etest/testdata/non_default_statefile_013/statefilefoo @@ -0,0 +1,25 @@ +{ + "version": 4, + "terraform_version": "0.13.0", + "serial": 1, + "lineage": "b69e96b9-250f-2004-c603-1b11dc3459c1", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "null_resource", + "name": "foo", + "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "3610244792381545397", + "triggers": null + }, + "private": "bnVsbA==" + } + ] + } + ] +} diff --git a/tfexec/internal/e2etest/util_test.go b/tfexec/internal/e2etest/util_test.go index faf50454..4dd87190 100644 --- a/tfexec/internal/e2etest/util_test.go +++ b/tfexec/internal/e2etest/util_test.go @@ -27,8 +27,6 @@ func runTest(t *testing.T, fixtureName string, cb func(t *testing.T, tfVersion * }, fixtureName, cb) } -// runTestVersions should probably not be used directly, better to use -// t.Skip in your test with a comment as to why you shouldn't test on a version func runTestVersions(t *testing.T, versions []string, fixtureName string, cb func(t *testing.T, tfVersion *version.Version, tf *tfexec.Terraform)) { t.Helper() diff --git a/tfexec/show.go b/tfexec/show.go index 937e8ccb..8c74c4bc 100644 --- a/tfexec/show.go +++ b/tfexec/show.go @@ -10,6 +10,8 @@ import ( tfjson "github.com/hashicorp/terraform-json" ) +// Show reads the default state path and outputs the state. +// To read a state or plan file, ShowState or ShowPlan must be used instead. func (tf *Terraform) Show(ctx context.Context) (*tfjson.State, error) { err := tf.compatible(ctx, tf0_12_0, nil) if err != nil { @@ -40,6 +42,77 @@ func (tf *Terraform) Show(ctx context.Context) (*tfjson.State, error) { return &ret, nil } +// ShowStateFile reads a given state file and outputs the state. +func (tf *Terraform) ShowStateFile(ctx context.Context, statePath string) (*tfjson.State, error) { + err := tf.compatible(ctx, tf0_12_0, nil) + if err != nil { + return nil, fmt.Errorf("terraform show -json was added in 0.12.0: %w", err) + } + + if statePath == "" { + return nil, fmt.Errorf("statePath cannot be blank: use Show() if not passing statePath") + } + + showCmd := tf.showCmd(ctx, statePath) + + var ret tfjson.State + var outBuf bytes.Buffer + showCmd.Stdout = &outBuf + + err = tf.runTerraformCmd(showCmd) + if err != nil { + return nil, err + } + + err = json.Unmarshal(outBuf.Bytes(), &ret) + if err != nil { + return nil, err + } + + err = ret.Validate() + if err != nil { + return nil, err + } + + return &ret, nil +} + +// ShowPlanFile reads a given plan file and outputs the plan. +func (tf *Terraform) ShowPlanFile(ctx context.Context, planPath string) (*tfjson.Plan, error) { + err := tf.compatible(ctx, tf0_12_0, nil) + if err != nil { + return nil, fmt.Errorf("terraform show -json was added in 0.12.0: %w", err) + } + + if planPath == "" { + return nil, fmt.Errorf("planPath cannot be blank: use Show() if not passing planPath") + } + + showCmd := tf.showCmd(ctx, planPath) + + var ret tfjson.Plan + var outBuf bytes.Buffer + showCmd.Stdout = &outBuf + + err = tf.runTerraformCmd(showCmd) + if err != nil { + return nil, err + } + + err = json.Unmarshal(outBuf.Bytes(), &ret) + if err != nil { + return nil, err + } + + err = ret.Validate() + if err != nil { + return nil, err + } + + return &ret, nil + +} + func (tf *Terraform) showCmd(ctx context.Context, args ...string) *exec.Cmd { allArgs := []string{"show", "-json", "-no-color"} allArgs = append(allArgs, args...) diff --git a/tfexec/show_test.go b/tfexec/show_test.go index 79fd4b5b..381f6300 100644 --- a/tfexec/show_test.go +++ b/tfexec/show_test.go @@ -29,3 +29,47 @@ func TestShowCmd(t *testing.T) { "-no-color", }, nil, showCmd) } + +func TestShowStateFileCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfVersion(t, testutil.Latest012)) + if err != nil { + t.Fatal(err) + } + + // empty env, to avoid environ mismatch in testing + tf.SetEnv(map[string]string{}) + + showCmd := tf.showCmd(context.Background(), "statefilepath") + + assertCmd(t, []string{ + "show", + "-json", + "-no-color", + "statefilepath", + }, nil, showCmd) +} + +func TestShowPlanFileCmd(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + + tf, err := NewTerraform(td, tfVersion(t, testutil.Latest012)) + if err != nil { + t.Fatal(err) + } + + // empty env, to avoid environ mismatch in testing + tf.SetEnv(map[string]string{}) + + showCmd := tf.showCmd(context.Background(), "planfilepath") + + assertCmd(t, []string{ + "show", + "-json", + "-no-color", + "planfilepath", + }, nil, showCmd) +}