diff --git a/internal/command/e2etest/pluggable_state_store_test.go b/internal/command/e2etest/pluggable_state_store_test.go index a8f59183b988..92238bd0fd3b 100644 --- a/internal/command/e2etest/pluggable_state_store_test.go +++ b/internal/command/e2etest/pluggable_state_store_test.go @@ -11,10 +11,12 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/e2e" "github.com/hashicorp/terraform/internal/getproviders" ) +// Tests using `terraform workspace` commands in combination with pluggable state storage. func TestPrimary_stateStore_workspaceCmd(t *testing.T) { if !canRunGoBuild { // We're running in a separate-build-then-run context, so we can't @@ -40,11 +42,11 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { // Move the provider binaries into a directory that we will point terraform // to using the -plugin-dir cli flag. platform := getproviders.CurrentPlatform.String() - hashiDir := "cache/registry.terraform.io/hashicorp/" - if err := os.MkdirAll(tf.Path(hashiDir, "simple6/0.0.1/", platform), os.ModePerm); err != nil { + fsMirrorPath := "cache/registry.terraform.io/hashicorp/simple6/0.0.1/" + if err := os.MkdirAll(tf.Path(fsMirrorPath, platform), os.ModePerm); err != nil { t.Fatal(err) } - if err := os.Rename(simple6ProviderExe, tf.Path(hashiDir, "simple6/0.0.1/", platform, "terraform-provider-simple6")); err != nil { + if err := os.Rename(simple6ProviderExe, tf.Path(fsMirrorPath, platform, "terraform-provider-simple6")); err != nil { t.Fatal(err) } @@ -119,3 +121,80 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) } } + +// Tests using `terraform state` subcommands in combination with pluggable state storage: +// > `terraform state show` +// > `terraform state list` +func TestPrimary_stateStore_stateCmds(t *testing.T) { + + if !canRunGoBuild { + // We're running in a separate-build-then-run context, so we can't + // currently execute this test which depends on being able to build + // new executable at runtime. + // + // (See the comment on canRunGoBuild's declaration for more information.) + t.Skip("can't run without building a new provider executable") + } + + t.Setenv(e2e.TestExperimentFlag, "true") + tfBin := e2e.GoBuild("github.com/hashicorp/terraform", "terraform") + + fixturePath := filepath.Join("testdata", "initialized-directory-with-state-store-fs") + tf := e2e.NewBinary(t, tfBin, fixturePath) + + workspaceDirName := "states" // see test fixture value for workspace_dir + + // In order to test integration with PSS we need a provider plugin implementing a state store. + // Here will build the simple6 (built with protocol v6) provider, which implements PSS. + simple6Provider := filepath.Join(tf.WorkDir(), "terraform-provider-simple6") + simple6ProviderExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple-v6/main", simple6Provider) + + // Move the provider binaries into the correct .terraform/providers/ directory + // that will contain provider binaries in an initialized working directory. + platform := getproviders.CurrentPlatform.String() + providerCachePath := ".terraform/providers/registry.terraform.io/hashicorp/simple6/0.0.1/" + if err := os.MkdirAll(tf.Path(providerCachePath, platform), os.ModePerm); err != nil { + t.Fatal(err) + } + if err := os.Rename(simple6ProviderExe, tf.Path(providerCachePath, platform, "terraform-provider-simple6")); err != nil { + t.Fatal(err) + } + + // Assert that the test starts with the default state present from test fixtures + defaultStateId := "default" + fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, defaultStateId, "terraform.tfstate")) + if err != nil { + t.Fatalf("failed to open default workspace's state file: %s", err) + } + if fi.Size() == 0 { + t.Fatal("default workspace's state file should not have size 0 bytes") + } + + //// List State: terraform state list + expectedResourceAddr := "terraform_data.my-data" + stdout, stderr, err := tf.Run("state", "list", "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg := expectedResourceAddr + "\n" // This is the only resource instance in the test fixture state + if stdout != expectedMsg { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } + + //// Show State: terraform state show + stdout, stderr, err = tf.Run("state", "show", expectedResourceAddr, "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + // show displays the state for the specified resource + expectedMsg = `# terraform_data.my-data: +resource "terraform_data" "my-data" { + id = "d71fb368-2ba1-fb4c-5bd9-6a2b7f05d60c" + input = "hello world" + output = "hello world" +} +` + if diff := cmp.Diff(stdout, expectedMsg); diff != "" { + t.Errorf("wrong result, diff:\n%s", diff) + } +} diff --git a/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/.terraform.lock.hcl b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/.terraform.lock.hcl new file mode 100644 index 000000000000..7a0db0a25a93 --- /dev/null +++ b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/.terraform.lock.hcl @@ -0,0 +1,6 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/simple6" { + version = "0.0.1" +} diff --git a/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/.terraform/terraform.tfstate b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/.terraform/terraform.tfstate new file mode 100644 index 000000000000..e297b792ce7b --- /dev/null +++ b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/.terraform/terraform.tfstate @@ -0,0 +1,16 @@ +{ + "version": 3, + "terraform_version": "1.15.0", + "state_store": { + "type": "simple6_fs", + "provider": { + "version": "0.0.1", + "source": "registry.terraform.io/hashicorp/simple6", + "config": {} + }, + "config": { + "workspace_dir": "states" + }, + "hash": 3942813381 + } +} \ No newline at end of file diff --git a/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/main.tf b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/main.tf new file mode 100644 index 000000000000..d2c773a6fca9 --- /dev/null +++ b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + simple6 = { + source = "registry.terraform.io/hashicorp/simple6" + } + } + + state_store "simple6_fs" { + provider "simple6" {} + + workspace_dir = "states" + } +} + +variable "name" { + default = "world" +} + +resource "terraform_data" "my-data" { + input = "hello ${var.name}" +} + +output "greeting" { + value = resource.terraform_data.my-data.output +} diff --git a/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/states/default/terraform.tfstate b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/states/default/terraform.tfstate new file mode 100644 index 000000000000..4feaaed87a08 --- /dev/null +++ b/internal/command/e2etest/testdata/initialized-directory-with-state-store-fs/states/default/terraform.tfstate @@ -0,0 +1,40 @@ +{ + "version": 4, + "terraform_version": "1.15.0", + "serial": 1, + "lineage": "9e13d881-e480-7a63-d47a-b4f5224e6743", + "outputs": { + "greeting": { + "value": "hello world", + "type": "string" + } + }, + "resources": [ + { + "mode": "managed", + "type": "terraform_data", + "name": "my-data", + "provider": "provider[\"terraform.io/builtin/terraform\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "d71fb368-2ba1-fb4c-5bd9-6a2b7f05d60c", + "input": { + "value": "hello world", + "type": "string" + }, + "output": { + "value": "hello world", + "type": "string" + }, + "triggers_replace": null + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + } + ], + "check_results": null +} \ No newline at end of file diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 829b73734b27..ed2c60e85968 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3674,7 +3674,7 @@ func TestInit_stateStore_configChanges(t *testing.T) { // The previous init implied by this test scenario would have created this. mockProvider.GetStatesResponse = &providers.GetStatesResponse{States: []string{"default"}} - mockProvider.MockStates = map[string]interface{}{"default": true} + mockProvider.MockStates = map[string]interface{}{"default": []byte(`{"version": 4,"terraform_version":"1.15.0","serial": 1,"lineage": "","outputs": {},"resources": [],"checks":[]}`)} mockProviderAddress := addrs.NewDefaultProvider("test") providerSource, close := newMockProviderSource(t, map[string][]string{ @@ -4371,8 +4371,17 @@ func mockPluggableStateStorageProvider() *testing_provider.MockProvider { }, }, }, - DataSources: map[string]providers.Schema{}, - ResourceTypes: map[string]providers.Schema{}, + DataSources: map[string]providers.Schema{}, + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "input": {Type: cty.String, Optional: true}, + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }, ListResourceTypes: map[string]providers.Schema{}, StateStores: map[string]providers.Schema{ pssName: { @@ -4412,9 +4421,7 @@ func mockPluggableStateStorageProvider() *testing_provider.MockProvider { mock.ReadStateBytesFn = func(req providers.ReadStateBytesRequest) providers.ReadStateBytesResponse { state := []byte{} if v, exist := mock.MockStates[req.StateId]; exist { - if s, ok := v.([]byte); ok { - state = s - } + state = v.([]byte) // If this panics, the mock has been set up with a bad MockStates value } return providers.ReadStateBytesResponse{ Bytes: state, diff --git a/internal/command/output.go b/internal/command/output.go index 1fb1d6ebcc51..df5a20c304c9 100644 --- a/internal/command/output.go +++ b/internal/command/output.go @@ -83,13 +83,13 @@ func (c *OutputCommand) Outputs(statePath string, view arguments.ViewType) (map[ } // Get the state - stateStore, sDiags := b.StateMgr(env) + sMgr, sDiags := b.StateMgr(env) if sDiags.HasErrors() { diags = diags.Append(fmt.Errorf("Failed to load state: %s", sDiags.Err())) return nil, diags } - output, err := stateStore.GetRootOutputValues(ctx) + output, err := sMgr.GetRootOutputValues(ctx) if err != nil { return nil, diags.Append(err) } diff --git a/internal/command/state_identities_test.go b/internal/command/state_identities_test.go index 64dfb5c1593c..aa3a3d809e88 100644 --- a/internal/command/state_identities_test.go +++ b/internal/command/state_identities_test.go @@ -4,6 +4,7 @@ package command import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -11,6 +12,9 @@ import ( "testing" "github.com/hashicorp/cli" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states/statefile" ) func TestStateIdentities(t *testing.T) { @@ -423,3 +427,68 @@ func TestStateIdentities_modules(t *testing.T) { }) } + +func TestStateIdentities_stateStore(t *testing.T) { + // We need configuration present to force pluggable state storage to be used + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get a state file, that contains identity information,as bytes + state := testStateWithIdentity() + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + stateBytes := stateBuf.Bytes() + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBytes, + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := cli.NewMockUi() + c := &StateIdentitiesCommand{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + }, + } + + args := []string{"-json"} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // Test that outputs were displayed + expected := `{ + "test_instance.bar": { + "id": "my-bar-id" + }, + "test_instance.foo": { + "id": "my-foo-id" + } +} +` + actual := ui.OutputWriter.String() + + // Normalize JSON strings + var expectedJSON, actualJSON map[string]interface{} + if err := json.Unmarshal([]byte(expected), &expectedJSON); err != nil { + t.Fatalf("Failed to unmarshal expected JSON: %s", err) + } + if err := json.Unmarshal([]byte(actual), &actualJSON); err != nil { + t.Fatalf("Failed to unmarshal actual JSON: %s", err) + } + + if !reflect.DeepEqual(expectedJSON, actualJSON) { + t.Fatalf("Expected:\n%q\n\nTo equal: %q", expected, actual) + } +} diff --git a/internal/command/state_list_test.go b/internal/command/state_list_test.go index c667c11754c8..f482259d6221 100644 --- a/internal/command/state_list_test.go +++ b/internal/command/state_list_test.go @@ -4,10 +4,15 @@ package command import ( + "bytes" "strings" "testing" "github.com/hashicorp/cli" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" ) func TestStateList(t *testing.T) { @@ -153,6 +158,78 @@ func TestStateList_backendCustomState(t *testing.T) { } } +// Tests using `terraform state list` subcommand in combination with pluggable state storage +// +// Note: Whereas other tests in this file use the local backend and require a state file in the test fixures, +// with pluggable state storage we can define the state via the mocked provider. +func TestStateList_stateStore(t *testing.T) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get bytes describing a state containing a resource + state := states.NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{ + "ami": "bar", + "network_interface": [{ + "device_index": 0, + "description": "Main network interface" + }] + }`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatal(err) + } + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBuf.Bytes(), + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := cli.NewMockUi() + c := &StateListCommand{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // Test that outputs were displayed + expected := "test_instance.foo\n" + actual := ui.OutputWriter.String() + if actual != expected { + t.Fatalf("Expected:\n%q\n\nTo equal: %q", actual, expected) + } +} + func TestStateList_backendOverrideState(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() diff --git a/internal/command/state_mv_test.go b/internal/command/state_mv_test.go index 9d6c2aa9034b..76deb8060535 100644 --- a/internal/command/state_mv_test.go +++ b/internal/command/state_mv_test.go @@ -4,6 +4,8 @@ package command import ( + "bytes" + "encoding/json" "fmt" "os" "path/filepath" @@ -14,7 +16,9 @@ import ( "github.com/hashicorp/cli" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" ) func TestStateMv(t *testing.T) { @@ -153,6 +157,130 @@ func TestStateMv(t *testing.T) { } +func TestStateMv_stateStore(t *testing.T) { + // Create a temporary working directory + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get bytes describing a state containing resources + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"foo","foo":"value","bar":"value"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "baz", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"baz","foo":"value","bar":"value"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBuf.Bytes(), + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + // Make the mock assert that the resource has been moved when the new state is persisted + oldAddr := "test_instance.foo" + newAddr := "test_instance.bar" + mockProvider.WriteStateBytesFn = func(req providers.WriteStateBytesRequest) providers.WriteStateBytesResponse { + r := bytes.NewReader(req.Bytes) + file, err := statefile.Read(r) + if err != nil { + t.Fatal(err) + } + + root := file.State.Modules[""] + if _, ok := root.Resources[oldAddr]; ok { + t.Fatalf("expected the new state to have moved the %s resource to the new addr %s, but the old addr is still present", + newAddr, + oldAddr, + ) + } + resource, ok := root.Resources[newAddr] + if !ok { + t.Fatalf("expected the moved resource to be at addr %s, but it isn't present", newAddr) + } + + // Check that the moved resource has the same state. + var key addrs.InstanceKey + type attrsJson struct { + Id string `json:"id"` + Foo string `json:"foo"` + Bar string `json:"bar"` + } + var data attrsJson + attrs := resource.Instances[key].Current.AttrsJSON + err = json.Unmarshal(attrs, &data) + if err != nil { + t.Fatal(err) + } + expectedData := attrsJson{ + Id: "foo", + Foo: "value", + Bar: "value", + } + if diff := cmp.Diff(expectedData, data); diff != "" { + t.Fatalf("the state of the moved resource doesn't match the original state:\nDiff = %s", diff) + } + + return providers.WriteStateBytesResponse{} + } + + ui := new(cli.MockUi) + c := &StateMvCommand{ + StateMeta{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + }, + }, + } + + args := []string{ + oldAddr, + newAddr, + } + if code := c.Run(args); code != 0 { + t.Fatalf("return code: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // See the mock definition above for logic that asserts what the new state will look like after moving the resource. +} + func TestStateMv_backupAndBackupOutOptionsWithNonLocalBackend(t *testing.T) { state := states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( diff --git a/internal/command/state_pull_test.go b/internal/command/state_pull_test.go index a4079152aa29..8c712b1821bb 100644 --- a/internal/command/state_pull_test.go +++ b/internal/command/state_pull_test.go @@ -10,6 +10,11 @@ import ( "testing" "github.com/hashicorp/cli" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" + "github.com/hashicorp/terraform/internal/terminal" ) func TestStatePull(t *testing.T) { @@ -43,6 +48,84 @@ func TestStatePull(t *testing.T) { } } +// Tests using `terraform state pull` subcommand in combination with pluggable state storage +// +// Note: Whereas other tests in this file use the local backend and require a state file in the test fixures, +// with pluggable state storage we can define the state via the mocked provider. +func TestStatePull_stateStore(t *testing.T) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get bytes describing a state containing a resource + state := states.NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{ + "input": "foobar" + }`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + stateBytes := stateBuf.Bytes() + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBytes, + } + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + ui := cli.NewMockUi() + streams, _ := terminal.StreamsForTesting(t) + c := &StatePullCommand{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: providerSource, + Ui: ui, + Streams: streams, + }, + } + + // `terraform show` command specifying a given resource addr + expectedResourceAddr := "test_instance.foo" + args := []string{expectedResourceAddr} + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // Test that the state in the output matches the original state + actual := ui.OutputWriter.Bytes() + if bytes.Equal(actual, stateBytes) { + t.Fatalf("expected:\n%s\n\nto include: %q", actual, stateBytes) + } +} + func TestStatePull_noState(t *testing.T) { tmp := t.TempDir() t.Chdir(tmp) diff --git a/internal/command/state_push_test.go b/internal/command/state_push_test.go index fb4475c7a9ec..bfe822ebfd0e 100644 --- a/internal/command/state_push_test.go +++ b/internal/command/state_push_test.go @@ -9,9 +9,12 @@ import ( "testing" "github.com/hashicorp/cli" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/backend/remote-state/inmem" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" ) func TestStatePush_empty(t *testing.T) { @@ -44,6 +47,48 @@ func TestStatePush_empty(t *testing.T) { } } +func TestStatePush_stateStore(t *testing.T) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("state-push-state-store-good"), td) + t.Chdir(td) + + expected := testStateRead(t, "replace.tfstate") + + // Create a mock that doesn't have any internal states. + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := new(cli.MockUi) + c := &StatePushCommand{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + }, + } + + args := []string{"replace.tfstate"} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // Access the pushed state from the mock's internal store + r := bytes.NewReader(mockProvider.MockStates["default"].([]byte)) + actual, err := statefile.Read(r) + if err != nil { + t.Fatal(err) + } + + if !actual.State.Equal(expected) { + t.Fatalf("bad: %#v", actual) + } +} + func TestStatePush_lockedState(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() diff --git a/internal/command/state_replace_provider_test.go b/internal/command/state_replace_provider_test.go index 06508fd9a435..e7afaa59812c 100644 --- a/internal/command/state_replace_provider_test.go +++ b/internal/command/state_replace_provider_test.go @@ -12,7 +12,9 @@ import ( "github.com/hashicorp/cli" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" ) func TestStateReplaceProvider(t *testing.T) { @@ -285,6 +287,97 @@ func TestStateReplaceProvider(t *testing.T) { }) } +func TestStateReplaceProvider_stateStore(t *testing.T) { + // Create a temporary working directory + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get bytes describing a state containing resources + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"foo","foo":"value","bar":"value"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "baz", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"baz","foo":"value","bar":"value"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBuf.Bytes(), + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := new(cli.MockUi) + c := &StateReplaceProviderCommand{ + StateMeta{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + }, + }, + } + + inputBuf := &bytes.Buffer{} + ui.InputReader = inputBuf + inputBuf.WriteString("yes\n") + + args := []string{ + "hashicorp/test", + "testing-corp/test", + } + if code := c.Run(args); code != 0 { + t.Fatalf("return code: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // For the two resources in the mocked state, we expect them both to be changed. + expectedOutputMsgs := []string{ + "- registry.terraform.io/hashicorp/test\n + registry.terraform.io/testing-corp/test\n", + "Successfully replaced provider for 2 resources.", + } + for _, msg := range expectedOutputMsgs { + if !strings.Contains(ui.OutputWriter.String(), msg) { + t.Fatalf("expected command output to include %q but it's not present in the output:\nOutput = %s", + msg, ui.OutputWriter.String()) + } + } +} + func TestStateReplaceProvider_docs(t *testing.T) { c := &StateReplaceProviderCommand{} diff --git a/internal/command/state_rm_test.go b/internal/command/state_rm_test.go index 7aece89f7d4a..60efc678c883 100644 --- a/internal/command/state_rm_test.go +++ b/internal/command/state_rm_test.go @@ -4,6 +4,7 @@ package command import ( + "bytes" "os" "path/filepath" "strings" @@ -12,7 +13,9 @@ import ( "github.com/hashicorp/cli" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" ) func TestStateRm(t *testing.T) { @@ -82,6 +85,102 @@ func TestStateRm(t *testing.T) { testStateOutput(t, backups[0], testStateRmOutputOriginal) } +func TestStateRm_stateStore(t *testing.T) { + // Create a temporary working directory + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get bytes describing a state containing resources + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).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.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "bar", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"foo","foo":"value","bar":"value"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBuf.Bytes(), + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + // Make the mock assert that the removed resource is not present when the new state is persisted + keptResource := "test_instance.bar" + removedResource := "test_instance.foo" + mockProvider.WriteStateBytesFn = func(req providers.WriteStateBytesRequest) providers.WriteStateBytesResponse { + r := bytes.NewReader(req.Bytes) + file, err := statefile.Read(r) + if err != nil { + t.Fatal(err) + } + + root := file.State.Modules[""] + if _, ok := root.Resources[keptResource]; !ok { + t.Fatalf("expected the new state to keep the %s resource, but it couldn't be found", keptResource) + } + if _, ok := root.Resources[removedResource]; ok { + t.Fatalf("expected the %s resource to be removed from the state, but it is present", removedResource) + } + return providers.WriteStateBytesResponse{} + } + + ui := new(cli.MockUi) + c := &StateRmCommand{ + StateMeta{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + }, + }, + } + + args := []string{ + removedResource, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // See the mock definition above for logic that asserts what the new state will look like after removing the resource. +} + func TestStateRmNotChildModule(t *testing.T) { state := states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( diff --git a/internal/command/state_show_test.go b/internal/command/state_show_test.go index de8386795e6a..5324fdb56332 100644 --- a/internal/command/state_show_test.go +++ b/internal/command/state_show_test.go @@ -4,13 +4,16 @@ package command import ( + "bytes" "strings" "testing" + "github.com/hashicorp/cli" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/terminal" "github.com/zclconf/go-cty/cty" ) @@ -332,6 +335,80 @@ func TestStateShow_configured_provider(t *testing.T) { } } +// Tests using `terraform state show` subcommand in combination with pluggable state storage +// +// Note: Whereas other tests in this file use the local backend and require a state file in the test fixures, +// with pluggable state storage we can define the state via the mocked provider. +func TestStateShow_stateStore(t *testing.T) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + // Get bytes describing a state containing a resource + state := states.NewState() + rootModule := state.RootModule() + rootModule.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{ + "input": "foobar" + }`), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(state, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + + // Create a mock that contains a persisted "default" state that uses the bytes from above. + mockProvider := mockPluggableStateStorageProvider() + mockProvider.MockStates = map[string]interface{}{ + "default": stateBuf.Bytes(), + } + mockProviderAddress := addrs.NewDefaultProvider("test") + + ui := cli.NewMockUi() + streams, done := terminal.StreamsForTesting(t) + c := &StateShowCommand{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + Ui: ui, + Streams: streams, + }, + } + + // `terraform show` command specifying a given resource addr + expectedResourceAddr := "test_instance.foo" + args := []string{expectedResourceAddr} + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + // Test that outputs were displayed + expected := "# test_instance.foo:\nresource \"test_instance\" \"foo\" {\n input = \"foobar\"\n}\n" + actual := output.Stdout() + if actual != expected { + t.Fatalf("Expected:\n%q\n\nTo equal: %q", actual, expected) + } +} + const testStateShowOutput = ` # test_instance.foo: resource "test_instance" "foo" { diff --git a/internal/command/testdata/state-push-state-store-good/.terraform.lock.hcl b/internal/command/testdata/state-push-state-store-good/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-push-state-store-good/.terraform.lock.hcl @@ -0,0 +1,6 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/test" { + version = "1.2.3" +} diff --git a/internal/command/testdata/state-push-state-store-good/.terraform/terraform.tfstate b/internal/command/testdata/state-push-state-store-good/.terraform/terraform.tfstate new file mode 100644 index 000000000000..4f96aa73ee7d --- /dev/null +++ b/internal/command/testdata/state-push-state-store-good/.terraform/terraform.tfstate @@ -0,0 +1,19 @@ +{ + "version": 3, + "serial": 0, + "lineage": "666f9301-7e65-4b19-ae23-71184bb19b03", + "state_store": { + "type": "test_store", + "config": { + "value": "foobar" + }, + "provider": { + "version": "1.2.3", + "source": "registry.terraform.io/hashicorp/test", + "config": { + "region": null + } + }, + "hash": 4158988729 + } +} \ No newline at end of file diff --git a/internal/command/testdata/state-push-state-store-good/main.tf b/internal/command/testdata/state-push-state-store-good/main.tf new file mode 100644 index 000000000000..34b58fdc0e2e --- /dev/null +++ b/internal/command/testdata/state-push-state-store-good/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "registry.terraform.io/hashicorp/test" + } + } + + state_store "test_store" { + provider "test" {} + + value = "foobar" + } +} diff --git a/internal/command/testdata/state-push-state-store-good/replace.tfstate b/internal/command/testdata/state-push-state-store-good/replace.tfstate new file mode 100644 index 000000000000..9921bc076254 --- /dev/null +++ b/internal/command/testdata/state-push-state-store-good/replace.tfstate @@ -0,0 +1,23 @@ +{ + "version": 4, + "serial": 0, + "lineage": "hello", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "null_resource", + "name": "b", + "provider": "provider.null", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "9051675049789185374", + "triggers": null + } + } + ] + } + ] +} diff --git a/internal/grpcwrap/provider6.go b/internal/grpcwrap/provider6.go index 2f0d0f7395f3..35a6679860da 100644 --- a/internal/grpcwrap/provider6.go +++ b/internal/grpcwrap/provider6.go @@ -982,6 +982,10 @@ func (p *provider6) ConfigureStateStore(ctx context.Context, req *tfplugin6.Conf return resp, nil } + // If this isn't present, chunk size is 0 and downstream code + // acts like there is no state at all. + p.chunkSize = configureResp.Capabilities.ChunkSize + resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, configureResp.Diagnostics) resp.Capabilities = &tfplugin6.StateStoreServerCapabilities{ ChunkSize: configureResp.Capabilities.ChunkSize,