diff --git a/internal/command/init_test.go b/internal/command/init_test.go index fed3a5e56b9f..f14c35b08b75 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3242,7 +3242,9 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { mockProvider := mockPluggableStateStorageProvider() mockProviderAddress := addrs.NewDefaultProvider("test") providerSource, close := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, + // The test fixture config has no version constraints, so the latest version will + // be used; below is the 'latest' version in the test world. + "hashicorp/test": {"1.2.3"}, }) defer close() @@ -3298,20 +3300,19 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { if s == nil { t.Fatal("expected backend state file to be created, but there isn't one") } - v1_0_0, _ := version.NewVersion("1.0.0") + v1_2_3, _ := version.NewVersion("1.2.3") expectedState := &workdir.StateStoreConfigState{ Type: "test_store", ConfigRaw: []byte("{\n \"value\": \"foobar\"\n }"), - Hash: uint64(2116468040), // Hash affected by config + Hash: uint64(4158988729), Provider: &workdir.ProviderConfigState{ - Version: v1_0_0, + Version: v1_2_3, Source: &tfaddr.Provider{ Hostname: tfaddr.DefaultProviderRegistryHost, Namespace: "hashicorp", Type: "test", }, ConfigRaw: []byte("{\n \"region\": null\n }"), - Hash: uint64(3976463117), // Hash of empty config }, } if diff := cmp.Diff(s.StateStore, expectedState); diff != "" { @@ -3556,6 +3557,110 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { // >>> Currently this is handled at a lower level in `internal/command/meta_backend_test.go` } +// Testing init's behaviors with `state_store` when run in a working directory where the configuration +// doesn't match the backend state file. +func TestInit_stateStore_configUnchanged(t *testing.T) { + // This matches the backend state test fixture in "state-store-unchanged" + v1_2_3, _ := version.NewVersion("1.2.3") + expectedState := &workdir.StateStoreConfigState{ + Type: "test_store", + ConfigRaw: []byte("{\n \"value\": \"foobar\"\n }"), + Hash: uint64(4158988729), + Provider: &workdir.ProviderConfigState{ + Version: v1_2_3, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "hashicorp", + Type: "test", + }, + ConfigRaw: []byte("{\n \"region\": null\n }"), + }, + } + + t.Run("init is successful when the configuration and backend state match", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that matches the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + // If the working directory was previously initialized successfully then at least + // one workspace is guaranteed to exist when a user is re-running init with no config + // changes since last init. So this test says `default` exists. + mockProvider.GetStatesResponse = &providers.GetStatesResponse{ + States: []string{"default"}, + } + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture + }) + defer close() + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + // Before running init, confirm the contents of the backend state file before + statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) + sMgr := &clistate.LocalState{Path: statePath} + if err := sMgr.RefreshState(); err != nil { + t.Fatal("Failed to load state:", err) + } + s := sMgr.State() + if s == nil { + t.Fatal("expected backend state file to be present, but there isn't one") + } + if diff := cmp.Diff(s.StateStore, expectedState); diff != "" { + t.Fatalf("unexpected diff in backend state file's description of state store:\n%s", diff) + } + + // Run init command + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + } + code := c.Run(args) + testOutput := done(t) + if code != 0 { + t.Fatalf("expected code 0 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedOutputs := []string{ + "Initializing the state store...", + "Terraform has been successfully initialized!", + } + for _, expected := range expectedOutputs { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to include %q, but got':\n %s", expected, output) + } + } + + // Confirm init was a no-op and backend state is unchanged afterwards + if err := sMgr.RefreshState(); err != nil { + t.Fatal("Failed to load state:", err) + } + s = sMgr.State() + if diff := cmp.Diff(s.StateStore, expectedState); diff != "" { + t.Fatalf("unexpected diff in backend state file's description of state store:\n%s", diff) + } + }) +} + // Testing init's behaviors with `state_store` when run in a working directory where the configuration // doesn't match the backend state file. func TestInit_stateStore_configChanges(t *testing.T) { @@ -3563,13 +3668,18 @@ func TestInit_stateStore_configChanges(t *testing.T) { // Create a temporary working directory with state store configuration // that doesn't match the backend state file td := t.TempDir() - testCopyDir(t, testFixturePath("state-store-reconfigure"), td) + testCopyDir(t, testFixturePath("state-store-changed/store-config"), td) t.Chdir(td) mockProvider := mockPluggableStateStorageProvider() + + // 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} + mockProviderAddress := addrs.NewDefaultProvider("test") providerSource, close := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, + "hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture }) defer close() @@ -3612,11 +3722,6 @@ func TestInit_stateStore_configChanges(t *testing.T) { } } - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists { - t.Fatal("expected the default workspace to be created during init, but it is missing") - } - // Assert contents of the backend state file statePath := filepath.Join(meta.DataDir(), DefaultStateFilename) sMgr := &clistate.LocalState{Path: statePath} @@ -3627,20 +3732,19 @@ func TestInit_stateStore_configChanges(t *testing.T) { if s == nil { t.Fatal("expected backend state file to be created, but there isn't one") } - v1_0_0, _ := version.NewVersion("1.0.0") + v1_2_3, _ := version.NewVersion("1.2.3") expectedState := &workdir.StateStoreConfigState{ Type: "test_store", ConfigRaw: []byte("{\n \"value\": \"changed-value\"\n }"), - Hash: uint64(1417640992), // Hash affected by config + Hash: uint64(1157855489), // The new hash after reconfiguring; this doesn't match the backend state test fixture Provider: &workdir.ProviderConfigState{ - Version: v1_0_0, + Version: v1_2_3, Source: &tfaddr.Provider{ Hostname: tfaddr.DefaultProviderRegistryHost, Namespace: "hashicorp", Type: "test", }, ConfigRaw: []byte("{\n \"region\": null\n }"), - Hash: uint64(3976463117), // Hash of empty config }, } if diff := cmp.Diff(s.StateStore, expectedState); diff != "" { @@ -3648,12 +3752,270 @@ func TestInit_stateStore_configChanges(t *testing.T) { } }) - // TODO(SarahFrench/radeksimko): Add more test cases related to changing the - // configuration and the forced need for state migration. - // More complicated situations might benefit from being separate tests altogether. - // Simpler scenarios that make sense to keep here are: - // 1) Changing config of the same state_store type - // 2) Changing config of the same provider (and version) used for PSS + t.Run("handling changed state store config is currently unimplemented", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that doesn't match the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-changed/store-config"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProvider.GetStatesResponse = &providers.GetStatesResponse{States: []string{"default"}} // The previous init implied by this test scenario would have created the default workspace. + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture + }) + defer close() + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedMsg := "Changing a state store configuration is not implemented yet" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) + } + + }) + + t.Run("handling changed state store provider config is currently unimplemented", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that doesn't match the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-changed/provider-config"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProvider.GetStatesResponse = &providers.GetStatesResponse{States: []string{"default"}} // The previous init implied by this test scenario would have created the default workspace. + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture + }) + defer close() + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedMsg := "Changing a state store configuration is not implemented yet" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) + } + }) + + t.Run("handling changed state store type in the same provider is currently unimplemented", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that doesn't match the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-changed/state-store-type"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + storeName := "test_store" + otherStoreName := "test_otherstore" + // Make the provider report that it contains a 2nd storage implementation with the above name + mockProvider.GetProviderSchemaResponse.StateStores[otherStoreName] = mockProvider.GetProviderSchemaResponse.StateStores[storeName] + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture + }) + defer close() + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedMsg := "Changing a state store configuration is not implemented yet" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) + } + }) + + t.Run("handling changing the provider used for state storage is currently unimplemented", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that doesn't match the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-changed/provider-used"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProvider.GetStatesResponse = &providers.GetStatesResponse{States: []string{"default"}} // The previous init implied by this test scenario would have created the default workspace. + + // Make a mock that implies its name is test2 based on returned schemas + mockProvider2 := mockPluggableStateStorageProvider() + mockProvider2.GetProviderSchemaResponse.StateStores["test2_store"] = mockProvider.GetProviderSchemaResponse.StateStores["test_store"] + delete(mockProvider2.GetProviderSchemaResponse.StateStores, "test_store") + + mockProviderAddress := addrs.NewDefaultProvider("test") + mockProviderAddress2 := addrs.NewDefaultProvider("test2") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, // Provider in backend state file fixture + "hashicorp/test2": {"1.2.3"}, // Provider now used in config + }) + defer close() + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), // test provider + mockProviderAddress2: providers.FactoryFixed(mockProvider2), // test2 provider + }, + }, + ProviderSource: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedMsg := "Changing a state store configuration is not implemented yet" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) + } + }) +} + +// Testing init's behaviors with `state_store` when the provider used for state storage in a previous init +// command is updated. +// +// TODO: Add a test case showing that downgrading provider version is ok as long as the schema version hasn't +// changed. We should also have a test demonstrating that downgrades when the schema version HAS changed will fail. +func TestInit_stateStore_providerUpgrade(t *testing.T) { + t.Run("handling upgrading the provider used for state storage is currently unimplemented", func(t *testing.T) { + // Create a temporary working directory with state store configuration + // that doesn't match the backend state file + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-changed/provider-upgraded"), td) + t.Chdir(td) + + mockProvider := mockPluggableStateStorageProvider() + mockProviderAddress := addrs.NewDefaultProvider("test") + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3", "9.9.9"}, // 1.2.3 is the version used in the backend state file, 9.9.9 is the version being upgraded to + }) + defer close() + + ui := new(cli.MockUi) + view, done := testView(t) + meta := Meta{ + Ui: ui, + View: view, + AllowExperimentalFeatures: true, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + mockProviderAddress: providers.FactoryFixed(mockProvider), + }, + }, + ProviderSource: providerSource, + } + c := &InitCommand{ + Meta: meta, + } + + args := []string{ + "-enable-pluggable-state-storage-experiment=true", + "-upgrade", + } + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("expected code 1 exit code, got %d, output: \n%s", code, testOutput.All()) + } + + // Check output + output := testOutput.All() + expectedMsg := "Changing a state store configuration is not implemented yet" + if !strings.Contains(output, expectedMsg) { + t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output) + } + }) } // newMockProviderSource is a helper to succinctly construct a mock provider diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 994a95b4ceec..46241932b09f 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -568,7 +568,7 @@ func (m *Meta) backendConfig(opts *BackendOpts) (*configs.Backend, int, tfdiags. // > Ensures that that state store type exists in the linked provider. // > Returns config that is the combination of config and any config overrides originally supplied via the CLI. // > Returns a hash of the config in the configuration files, i.e. excluding overrides -func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, int, tfdiags.Diagnostics) { +func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics c := opts.StateStoreConfig @@ -580,7 +580,15 @@ func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, in Summary: "Missing state store configuration", Detail: "Terraform attempted to configure a state store when no parsed 'state_store' configuration was present. This is a bug in Terraform and should be reported.", }) - return nil, 0, 0, diags + return nil, 0, diags + } + + // Get the provider version from locks, as this impacts the hash + // NOTE: this assumes that we will never allow users to override config definint which provider is used for state storage + stateStoreProviderVersion, vDiags := getStateStorageProviderVersion(opts.StateStoreConfig, opts.Locks) + diags = diags.Append(vDiags) + if vDiags.HasErrors() { + return nil, 0, diags } // Check - is the state store type in the config supported by the provider? @@ -590,12 +598,12 @@ func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, in Summary: "Missing provider details when configuring state store", Detail: "Terraform attempted to configure a state store and no provider factory was available to launch it. This is a bug in Terraform and should be reported.", }) - return nil, 0, 0, diags + return nil, 0, diags } provider, err := opts.ProviderFactory() if err != nil { diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) - return nil, 0, 0, diags + return nil, 0, diags } defer provider.Close() // Stop the child process once we're done with it here. @@ -610,7 +618,7 @@ func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, in c.ProviderAddr), Subject: &c.DeclRange, }) - return nil, 0, 0, diags + return nil, 0, diags } stateStoreSchema, exists := resp.StateStores[c.Type] @@ -628,7 +636,7 @@ func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, in c.ProviderAddr, suggestion), Subject: &c.DeclRange, }) - return nil, 0, 0, diags + return nil, 0, diags } // We know that the provider contains a state store with the correct type name. @@ -638,7 +646,7 @@ func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, in // > Apply any overrides configBody := c.Config - stateStoreHash, providerHash, diags := c.Hash(stateStoreSchema.Body, resp.Provider.Body) + hash, diags := c.Hash(stateStoreSchema.Body, resp.Provider.Body, stateStoreProviderVersion) // If we have an override configuration body then we must apply it now. if opts.ConfigOverride != nil { @@ -646,13 +654,13 @@ func (m *Meta) stateStoreConfig(opts *BackendOpts) (*configs.StateStore, int, in configBody = configs.MergeBodies(configBody, opts.ConfigOverride) } - log.Printf("[TRACE] Meta.Backend: built configuration for %q state_store with hash value %d and nested provider block with hash value %d", c.Type, stateStoreHash, providerHash) + log.Printf("[TRACE] Meta.Backend: built configuration for %q state_store with hash value %d", c.Type, hash) // We'll shallow-copy configs.StateStore here so that we can replace the // body without affecting others that hold this reference. configCopy := *c configCopy.Config = configBody - return &configCopy, stateStoreHash, providerHash, diags + return &configCopy, hash, diags } // backendFromConfig returns the initialized (not configured) backend @@ -673,13 +681,11 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // Get the local 'backend' or 'state_store' configuration. var backendConfig *configs.Backend var stateStoreConfig *configs.StateStore - var backendHash int - var stateStoreHash int - var stateStoreProviderHash int + var cHash int if opts.StateStoreConfig != nil { // state store has been parsed from config and is included in opts var ssDiags tfdiags.Diagnostics - stateStoreConfig, stateStoreHash, stateStoreProviderHash, ssDiags = m.stateStoreConfig(opts) + stateStoreConfig, cHash, ssDiags = m.stateStoreConfig(opts) diags = diags.Append(ssDiags) if ssDiags.HasErrors() { return nil, diags @@ -688,7 +694,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // backend config may or may not have been parsed and included in opts, // or may not exist in config at all (default/implied local backend) var beDiags tfdiags.Diagnostics - backendConfig, backendHash, beDiags = m.backendConfig(opts) + backendConfig, cHash, beDiags = m.backendConfig(opts) diags = diags.Append(beDiags) if beDiags.HasErrors() { return nil, diags @@ -779,7 +785,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di return nil, diags } - return m.backend_c_r_S(backendConfig, backendHash, sMgr, true, opts) + return m.backend_c_r_S(backendConfig, cHash, sMgr, true, opts) // We're unsetting a state_store (moving from state_store => local) case stateStoreConfig == nil && !s.StateStore.Empty() && @@ -809,7 +815,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di } return nil, diags } - return m.backend_C_r_s(backendConfig, backendHash, sMgr, opts) + return m.backend_C_r_s(backendConfig, cHash, sMgr, opts) // Configuring a state store for the first time or -reconfigure flag was used case stateStoreConfig != nil && s.StateStore.Empty() && @@ -830,7 +836,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di return nil, diags } - return m.stateStore_C_s(stateStoreConfig, stateStoreHash, stateStoreProviderHash, sMgr, opts) + return m.stateStore_C_s(stateStoreConfig, cHash, sMgr, opts) // Migration from state store to backend case backendConfig != nil && s.Backend.Empty() && @@ -870,7 +876,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // We're not initializing // AND the backend cache hash values match, indicating that the stored config is valid and completely unchanged. // AND we're not providing any overrides. An override can mean a change overriding an unchanged backend block (indicated by the hash value). - if (uint64(backendHash) == s.Backend.Hash) && (!opts.Init || opts.ConfigOverride == nil) { + if (uint64(cHash) == s.Backend.Hash) && (!opts.Init || opts.ConfigOverride == nil) { log.Printf("[TRACE] Meta.Backend: using already-initialized, unchanged %q backend configuration", backendConfig.Type) savedBackend, diags := m.savedBackend(sMgr) // Verify that selected workspace exist. Otherwise prompt user to create one @@ -898,7 +904,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // It's possible for a backend to be unchanged, and the config itself to // have changed by moving a parameter from the config to `-backend-config` // In this case, we update the Hash. - moreDiags = m.updateSavedBackendHash(backendHash, sMgr) + moreDiags = m.updateSavedBackendHash(cHash, sMgr) if moreDiags.HasErrors() { return nil, diags } @@ -929,7 +935,7 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di } log.Printf("[WARN] backend config has changed since last init") - return m.backend_C_r_S_changed(backendConfig, backendHash, sMgr, true, opts) + return m.backend_C_r_S_changed(backendConfig, cHash, sMgr, true, opts) // Potentially changing a state store configuration case backendConfig == nil && s.Backend.Empty() && @@ -944,6 +950,26 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // > Changing how the store is configured. // > Allowing values to be moved between partial overrides and config + // We're not initializing + // AND the config's and backend state file's hash values match, indicating that the stored config is valid and completely unchanged. + // AND we're not providing any overrides. An override can mean a change overriding an unchanged backend block (indicated by the hash value). + if (uint64(cHash) == s.StateStore.Hash) && (!opts.Init || opts.ConfigOverride == nil) { + log.Printf("[TRACE] Meta.Backend: using already-initialized, unchanged %q state_store configuration", stateStoreConfig.Type) + savedStateStore, sssDiags := m.savedStateStore(sMgr, opts.ProviderFactory) + diags = diags.Append(sssDiags) + // Verify that selected workspace exist. Otherwise prompt user to create one + if opts.Init && savedStateStore != nil { + if err := m.selectWorkspace(savedStateStore); err != nil { + diags = diags.Append(err) + return nil, diags + } + } + return savedStateStore, diags + } + + // Above caters only for unchanged config + // but this switch case will also handle changes, + // which isn't implemented yet. return nil, diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Not implemented yet", @@ -1569,7 +1595,7 @@ func (m *Meta) updateSavedBackendHash(cHash int, sMgr *clistate.LocalState) tfdi //------------------------------------------------------------------- // Configuring a state_store for the first time. -func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, providerHash int, backendSMgr *clistate.LocalState, opts *BackendOpts) (backend.Backend, tfdiags.Diagnostics) { +func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backendSMgr *clistate.LocalState, opts *BackendOpts) (backend.Backend, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics vt := arguments.ViewJSON @@ -1691,33 +1717,21 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, provide } else { // The provider is not built in and is being managed by Terraform // This is the most common scenario, by far. - pLock := opts.Locks.Provider(c.ProviderAddr) - if pLock == nil { - diags = diags.Append(fmt.Errorf("The provider %s (%q) is not present in the lockfile, despite being used for state store %q. This is a bug in Terraform and should be reported.", - c.Provider.Name, - c.ProviderAddr, - c.Type)) - return nil, diags - } - var err error - pVersion, err = providerreqs.GoVersionFromVersion(pLock.Version()) - if err != nil { - diags = diags.Append(fmt.Errorf("Failed obtain the in-use version of provider %s (%q) when recording backend state for state store %q. This is a bug in Terraform and should be reported: %w", - c.Provider.Name, - c.ProviderAddr, - c.Type, - err)) + var vDiags tfdiags.Diagnostics + pVersion, vDiags = getStateStorageProviderVersion(c, opts.Locks) + diags = diags.Append(vDiags) + if vDiags.HasErrors() { return nil, diags } } } + s.StateStore = &workdir.StateStoreConfigState{ Type: c.Type, Hash: uint64(stateStoreHash), Provider: &workdir.ProviderConfigState{ Source: &c.ProviderAddr, Version: pVersion, - Hash: uint64(providerHash), }, } s.StateStore.SetConfig(storeConfigVal, b.ConfigSchema()) @@ -1794,6 +1808,46 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, provide return b, diags } +// getStateStorageProviderVersion gets the current version of the state store provider that's in use. This is achieved +// by inspecting the current locks. +// +// This function assumes that calling code has checked whether the provider is fully managed by Terraform, +// or is built-in, before using this method and is prepared to receive a nil Version. +func getStateStorageProviderVersion(c *configs.StateStore, locks *depsfile.Locks) (*version.Version, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + var pVersion *version.Version + + isBuiltin := c.ProviderAddr.Hostname == addrs.BuiltInProviderHost + isReattached, err := reattach.IsProviderReattached(c.ProviderAddr, os.Getenv("TF_REATTACH_PROVIDERS")) + if err != nil { + diags = diags.Append(err) + return nil, diags + } + if isBuiltin || isReattached { + return nil, nil // nil Version returned + } + + pLock := locks.Provider(c.ProviderAddr) + if pLock == nil { + diags = diags.Append(fmt.Errorf("The provider %s (%q) is not present in the lockfile, despite being used for state store %q. This is a bug in Terraform and should be reported.", + c.Provider.Name, + c.ProviderAddr, + c.Type)) + return nil, diags + } + pVersion, err = providerreqs.GoVersionFromVersion(pLock.Version()) + if err != nil { + diags = diags.Append(fmt.Errorf("Failed obtain the in-use version of provider %s (%q) used with state store %q. This is a bug in Terraform and should be reported: %w", + c.Provider.Name, + c.ProviderAddr, + c.Type, + err)) + return nil, diags + } + + return pVersion, diags +} + // createDefaultWorkspace receives a backend made using a pluggable state store, and details about that store's config, // and persists an empty state file in the default workspace. By creating this artifact we ensure that the default // workspace is created and usable by Terraform in later operations. diff --git a/internal/command/meta_backend_test.go b/internal/command/meta_backend_test.go index ba766e520598..116940796a72 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -16,6 +16,7 @@ import ( "github.com/apparentlymart/go-versions/versions" "github.com/hashicorp/cli" + version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" tfaddr "github.com/hashicorp/terraform-registry-address" @@ -2124,47 +2125,6 @@ func TestMetaBackend_configuredStateStoreUnset(t *testing.T) { } } -// Changing a configured state store -// -// TODO(SarahFrench/radeksimko): currently this test only confirms that we're hitting the switch -// case for this scenario, and will need to be updated when that init feature is implemented. -// ALSO, this test will need to be split into multiple scenarios in future. -func TestMetaBackend_changeConfiguredStateStore(t *testing.T) { - td := t.TempDir() - testCopyDir(t, testFixturePath("state-store-changed"), td) - t.Chdir(td) - - // Setup the meta - m := testMetaBackend(t, nil) - m.AllowExperimentalFeatures = true - - // Get the state store's config - mod, loadDiags := m.loadSingleModule(td) - if loadDiags.HasErrors() { - t.Fatalf("unexpected error when loading test config: %s", loadDiags.Err()) - } - - // Get mock provider to be used during init - // - // This imagines a provider called "test" that contains - // a pluggable state store implementation called "store". - mock := testStateStoreMock(t) - - // Get the operations backend - _, beDiags := m.Backend(&BackendOpts{ - Init: true, - StateStoreConfig: mod.StateStore, - ProviderFactory: providers.FactoryFixed(mock), - }) - if !beDiags.HasErrors() { - t.Fatal("expected an error to be returned during partial implementation of PSS") - } - wantErr := "Changing a state store configuration is not implemented yet" - if !strings.Contains(beDiags.Err().Error(), wantErr) { - t.Fatalf("expected the returned error to contain %q, but got: %s", wantErr, beDiags.Err()) - } -} - // Changing from using backend to state_store // // TODO(SarahFrench/radeksimko): currently this test only confirms that we're hitting the switch @@ -2191,10 +2151,23 @@ func TestMetaBackend_configuredBackendToStateStore(t *testing.T) { mock := testStateStoreMock(t) // Get the operations backend + locks := depsfile.NewLocks() + providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test") + constraint, err := providerreqs.ParseVersionConstraints(">1.0.0") + if err != nil { + t.Fatalf("test setup failed when making constraint: %s", err) + } + locks.SetProvider( + providerAddr, + versions.MustParseVersion("9.9.9"), + constraint, + []providerreqs.Hash{""}, + ) _, beDiags := m.Backend(&BackendOpts{ Init: true, StateStoreConfig: mod.StateStore, ProviderFactory: providers.FactoryFixed(mock), + Locks: locks, }) if !beDiags.HasErrors() { t.Fatal("expected an error to be returned during partial implementation of PSS") @@ -2247,6 +2220,19 @@ func TestMetaBackend_configuredStateStoreToBackend(t *testing.T) { func TestMetaBackend_configureStateStoreVariableUse(t *testing.T) { wantErr := "Variables not allowed" + locks := depsfile.NewLocks() + providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test") + constraint, err := providerreqs.ParseVersionConstraints(">1.0.0") + if err != nil { + t.Fatalf("test setup failed when making constraint: %s", err) + } + locks.SetProvider( + providerAddr, + versions.MustParseVersion("9.9.9"), + constraint, + []providerreqs.Hash{""}, + ) + cases := map[string]struct { fixture string wantErr string @@ -2289,6 +2275,7 @@ func TestMetaBackend_configureStateStoreVariableUse(t *testing.T) { Init: true, StateStoreConfig: mod.StateStore, ProviderFactory: providers.FactoryFixed(mock), + Locks: locks, }) if err == nil { t.Fatal("should error") @@ -2339,7 +2326,7 @@ func TestSavedStateStore(t *testing.T) { // Create a temporary working directory chunkSize := 42 td := t.TempDir() - testCopyDir(t, testFixturePath("state-store-changed"), td) // Fixtures with config that differs from backend state file + testCopyDir(t, testFixturePath("state-store-changed/store-config"), td) // Fixtures with config that differs from backend state file t.Chdir(td) // Make a state manager for accessing the backend state file, @@ -2360,8 +2347,9 @@ func TestSavedStateStore(t *testing.T) { mock.ConfigureProviderFn = func(req providers.ConfigureProviderRequest) providers.ConfigureProviderResponse { // Assert that the state store is configured using backend state file values from the fixtures config := req.Config.AsValueMap() - if config["region"].AsString() != "old-value" { - t.Fatalf("expected the provider to be configured with values from the backend state file (the string \"old-value\"), not the config. Got: %#v", config) + if v, ok := config["region"]; ok && (v.Equals(cty.NullVal(cty.String)) != cty.True) { + // The backend state file has a null value for region, so if we're here we've somehow got a non-null value + t.Fatalf("expected the provider to be configured with values from the backend state file (where region is unset/null), not the config. Got value: %#v", v) } return providers.ConfigureProviderResponse{} } @@ -2429,7 +2417,7 @@ func TestSavedStateStore(t *testing.T) { t.Run("error - when there's no state stores in provider", func(t *testing.T) { // Create a temporary working directory td := t.TempDir() - testCopyDir(t, testFixturePath("state-store-changed"), td) // Fixtures with config that differs from backend state file + testCopyDir(t, testFixturePath("state-store-changed/store-config"), td) // Fixtures with config that differs from backend state file t.Chdir(td) // Make a state manager for accessing the backend state file, @@ -2461,7 +2449,7 @@ func TestSavedStateStore(t *testing.T) { t.Run("error - when there's no matching state store in provider Terraform suggests different identifier", func(t *testing.T) { // Create a temporary working directory td := t.TempDir() - testCopyDir(t, testFixturePath("state-store-changed"), td) // Fixtures with config that differs from backend state file + testCopyDir(t, testFixturePath("state-store-changed/store-config"), td) // Fixtures with config that differs from backend state file t.Chdir(td) // Make a state manager for accessing the backend state file, @@ -2723,6 +2711,19 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { ProviderAddr: addrs.NewDefaultProvider("test"), } + locks := depsfile.NewLocks() + providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test") + constraint, err := providerreqs.ParseVersionConstraints(">1.0.0") + if err != nil { + t.Fatalf("test setup failed when making constraint: %s", err) + } + locks.SetProvider( + providerAddr, + versions.MustParseVersion("9.9.9"), + constraint, + []providerreqs.Hash{""}, + ) + t.Run("override config can change values of custom attributes in the state_store block", func(t *testing.T) { overrideValue := "overridden" configOverride := configs.SynthBody("synth", map[string]cty.Value{"value": cty.StringVal(overrideValue)}) @@ -2732,10 +2733,11 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { ConfigOverride: configOverride, ProviderFactory: providers.FactoryFixed(mock), Init: true, + Locks: locks, } m := testMetaBackend(t, nil) - finalConfig, _, _, diags := m.stateStoreConfig(opts) + finalConfig, _, diags := m.stateStoreConfig(opts) if diags.HasErrors() { t.Fatalf("unexpected errors: %s", diags.Err()) } @@ -2759,10 +2761,11 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { opts := &BackendOpts{ StateStoreConfig: nil, //unset Init: true, + Locks: locks, } m := testMetaBackend(t, nil) - _, _, _, diags := m.stateStoreConfig(opts) + _, _, diags := m.stateStoreConfig(opts) if !diags.HasErrors() { t.Fatal("expected errors but got none") } @@ -2780,10 +2783,11 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { StateStoreConfig: config, ProviderFactory: nil, // unset Init: true, + Locks: locks, } m := testMetaBackend(t, nil) - _, _, _, diags := m.stateStoreConfig(opts) + _, _, diags := m.stateStoreConfig(opts) if !diags.HasErrors() { t.Fatal("expected errors but got none") } @@ -2804,10 +2808,11 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { StateStoreConfig: config, ProviderFactory: providers.FactoryFixed(mock), Init: true, + Locks: locks, } m := testMetaBackend(t, nil) - _, _, _, diags := m.stateStoreConfig(opts) + _, _, diags := m.stateStoreConfig(opts) if !diags.HasErrors() { t.Fatal("expected errors but got none") } @@ -2831,10 +2836,11 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { StateStoreConfig: config, ProviderFactory: providers.FactoryFixed(mock), Init: true, + Locks: locks, } m := testMetaBackend(t, nil) - _, _, _, diags := m.stateStoreConfig(opts) + _, _, diags := m.stateStoreConfig(opts) if !diags.HasErrors() { t.Fatal("expected errors but got none") } @@ -2855,6 +2861,107 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { }) } +func Test_getStateStorageProviderVersion(t *testing.T) { + // Locks only contain hashicorp/test provider + locks := depsfile.NewLocks() + providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test") + constraint, err := providerreqs.ParseVersionConstraints(">1.0.0") + if err != nil { + t.Fatalf("test setup failed when making constraint: %s", err) + } + setVersion := versions.MustParseVersion("9.9.9") + locks.SetProvider( + providerAddr, + setVersion, + constraint, + []providerreqs.Hash{""}, + ) + + t.Run("returns the version of the provider represented in the locks", func(t *testing.T) { + c := &configs.StateStore{ + Provider: &configs.Provider{}, + ProviderAddr: tfaddr.NewProvider(addrs.DefaultProviderRegistryHost, "hashicorp", "test"), + } + v, diags := getStateStorageProviderVersion(c, locks) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + expectedVersion, err := providerreqs.GoVersionFromVersion(setVersion) + if err != nil { + t.Fatalf("test setup failed when making expected version: %s", err) + } + if !v.Equal(expectedVersion) { + t.Fatalf("expected version to be %#v, got %#v", expectedVersion, v) + } + }) + + t.Run("returns a nil version when using a builtin provider", func(t *testing.T) { + c := &configs.StateStore{ + Provider: &configs.Provider{}, + ProviderAddr: tfaddr.NewProvider(addrs.BuiltInProviderHost, addrs.BuiltInProviderNamespace, "test"), + } + v, diags := getStateStorageProviderVersion(c, locks) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + var expectedVersion *version.Version = nil + if !v.Equal(expectedVersion) { + t.Fatalf("expected version to be %#v, got %#v", expectedVersion, v) + } + }) + + t.Run("returns a nil version when using a re-attached provider", func(t *testing.T) { + t.Setenv("TF_REATTACH_PROVIDERS", `{ + "test": { + "Protocol": "grpc", + "ProtocolVersion": 6, + "Pid": 12345, + "Test": true, + "Addr": { + "Network": "unix", + "String":"/var/folders/xx/abcde12345/T/plugin12345" + } + } + }`) + c := &configs.StateStore{ + Provider: &configs.Provider{}, + ProviderAddr: tfaddr.NewProvider(addrs.DefaultProviderRegistryHost, "hashicorp", "test"), + } + v, diags := getStateStorageProviderVersion(c, locks) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + var expectedVersion *version.Version = nil + if !v.Equal(expectedVersion) { + t.Fatalf("expected version to be %#v, got %#v", expectedVersion, v) + } + }) + + t.Run("returns an error diagnostic when version info cannot be obtained from locks", func(t *testing.T) { + c := &configs.StateStore{ + Type: "missing-provider_foobar", + Provider: &configs.Provider{ + Name: "missing-provider", + }, + ProviderAddr: tfaddr.NewProvider(addrs.DefaultProviderRegistryHost, "hashicorp", "missing-provider"), + } + _, diags := getStateStorageProviderVersion(c, locks) + if !diags.HasErrors() { + t.Fatal("expected errors but got none") + } + expectMsg := "not present in the lockfile" + if !strings.Contains(diags.Err().Error(), expectMsg) { + t.Fatalf("expected error to include %q but got: %s", + expectMsg, + diags.Err(), + ) + } + }) +} + func testMetaBackend(t *testing.T, args []string) *Meta { var m Meta m.Ui = new(cli.MockUi) diff --git a/internal/command/testdata/state-store-changed/provider-config/.terraform.lock.hcl b/internal/command/testdata/state-store-changed/provider-config/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-config/.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-store-changed/.terraform/terraform.tfstate b/internal/command/testdata/state-store-changed/provider-config/.terraform/terraform.tfstate similarity index 58% rename from internal/command/testdata/state-store-changed/.terraform/terraform.tfstate rename to internal/command/testdata/state-store-changed/provider-config/.terraform/terraform.tfstate index 2438ce6d2f8f..4f96aa73ee7d 100644 --- a/internal/command/testdata/state-store-changed/.terraform/terraform.tfstate +++ b/internal/command/testdata/state-store-changed/provider-config/.terraform/terraform.tfstate @@ -5,16 +5,15 @@ "state_store": { "type": "test_store", "config": { - "value": "old-value" + "value": "foobar" }, "provider": { "version": "1.2.3", - "source": "registry.terraform.io/my-org/foo", + "source": "registry.terraform.io/hashicorp/test", "config": { - "region": "old-value" - }, - "hash": 12345 + "region": null + } }, - "hash": 12345 + "hash": 4158988729 } } \ No newline at end of file diff --git a/internal/command/testdata/state-store-changed/main.tf b/internal/command/testdata/state-store-changed/provider-config/main.tf similarity index 54% rename from internal/command/testdata/state-store-changed/main.tf rename to internal/command/testdata/state-store-changed/provider-config/main.tf index 3202130af995..fefe037c7336 100644 --- a/internal/command/testdata/state-store-changed/main.tf +++ b/internal/command/testdata/state-store-changed/provider-config/main.tf @@ -6,9 +6,9 @@ terraform { } state_store "test_store" { provider "test" { - region = "changed-value" # changed versus backend state file + region = "new-value" # changed versus backend state file } - value = "changed-value" # changed versus backend state file + value = "foobar" } } diff --git a/internal/command/testdata/state-store-changed/provider-upgraded/.terraform.lock.hcl b/internal/command/testdata/state-store-changed/provider-upgraded/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-upgraded/.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-store-changed/provider-upgraded/.terraform/terraform.tfstate b/internal/command/testdata/state-store-changed/provider-upgraded/.terraform/terraform.tfstate new file mode 100644 index 000000000000..9bdea48296ac --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-upgraded/.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": "foobar" + } + }, + "hash": 3395824466 + } +} \ No newline at end of file diff --git a/internal/command/testdata/state-store-changed/provider-upgraded/main.tf b/internal/command/testdata/state-store-changed/provider-upgraded/main.tf new file mode 100644 index 000000000000..6ca83e8d2ba4 --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-upgraded/main.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + # No version constraints here; we assume the test using this fixture forces the latest provider version + # to not match the backend state file in this folder. + } + } + state_store "test_store" { + provider "test" { + region = "foobar" + } + + value = "foobar" + } +} diff --git a/internal/command/testdata/state-store-changed/provider-used/.terraform.lock.hcl b/internal/command/testdata/state-store-changed/provider-used/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-used/.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-store-changed/provider-used/.terraform/terraform.tfstate b/internal/command/testdata/state-store-changed/provider-used/.terraform/terraform.tfstate new file mode 100644 index 000000000000..9bdea48296ac --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-used/.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": "foobar" + } + }, + "hash": 3395824466 + } +} \ No newline at end of file diff --git a/internal/command/testdata/state-store-changed/provider-used/main.tf b/internal/command/testdata/state-store-changed/provider-used/main.tf new file mode 100644 index 000000000000..f0f5cbff5dd0 --- /dev/null +++ b/internal/command/testdata/state-store-changed/provider-used/main.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + test2 = { + source = "hashicorp/test2" + } + } + + # changed to using `test2` provider, versus `test` used in the backend state file + state_store "test2_store" { + provider "test2" { + region = "foobar" + } + + value = "foobar" + } +} diff --git a/internal/command/testdata/state-store-changed/state-store-type/.terraform/terraform.tfstate b/internal/command/testdata/state-store-changed/state-store-type/.terraform/terraform.tfstate new file mode 100644 index 000000000000..9bdea48296ac --- /dev/null +++ b/internal/command/testdata/state-store-changed/state-store-type/.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": "foobar" + } + }, + "hash": 3395824466 + } +} \ No newline at end of file diff --git a/internal/command/testdata/state-store-changed/state-store-type/main.tf b/internal/command/testdata/state-store-changed/state-store-type/main.tf new file mode 100644 index 000000000000..6db380a2df66 --- /dev/null +++ b/internal/command/testdata/state-store-changed/state-store-type/main.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_otherstore" { # changed store type versus backend state file; test_otherstore versus test_store + provider "test" { + region = "foobar" + } + + value = "foobar" + } +} diff --git a/internal/command/testdata/state-store-reconfigure/.terraform/terraform.tfstate b/internal/command/testdata/state-store-changed/store-config/.terraform/terraform.tfstate similarity index 78% rename from internal/command/testdata/state-store-reconfigure/.terraform/terraform.tfstate rename to internal/command/testdata/state-store-changed/store-config/.terraform/terraform.tfstate index 34cf4c7bf6ba..91ccd5b4350f 100644 --- a/internal/command/testdata/state-store-reconfigure/.terraform/terraform.tfstate +++ b/internal/command/testdata/state-store-changed/store-config/.terraform/terraform.tfstate @@ -10,9 +10,10 @@ "provider": { "version": "1.2.3", "source": "registry.terraform.io/hashicorp/test", - "config": {}, - "hash": 12345 + "config": { + "region": null + } }, - "hash": 12345 + "hash": 1505635192 } } \ No newline at end of file diff --git a/internal/command/testdata/state-store-reconfigure/main.tf b/internal/command/testdata/state-store-changed/store-config/main.tf similarity index 100% rename from internal/command/testdata/state-store-reconfigure/main.tf rename to internal/command/testdata/state-store-changed/store-config/main.tf diff --git a/internal/command/testdata/state-store-to-backend/.terraform.lock.hcl b/internal/command/testdata/state-store-to-backend/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-store-to-backend/.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-store-to-backend/.terraform/terraform.tfstate b/internal/command/testdata/state-store-to-backend/.terraform/terraform.tfstate index cfb4e3d72ade..b7e79f249766 100644 --- a/internal/command/testdata/state-store-to-backend/.terraform/terraform.tfstate +++ b/internal/command/testdata/state-store-to-backend/.terraform/terraform.tfstate @@ -10,9 +10,10 @@ "provider": { "version": "1.2.3", "source": "registry.terraform.io/hashicorp/test", - "config": {}, - "hash": 12345 + "config": { + "region": null + } }, - "hash": 12345 + "hash": 0 } } \ No newline at end of file diff --git a/internal/command/testdata/state-store-unchanged/.terraform.lock.hcl b/internal/command/testdata/state-store-unchanged/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-store-unchanged/.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-store-unchanged/.terraform/terraform.tfstate b/internal/command/testdata/state-store-unchanged/.terraform/terraform.tfstate new file mode 100644 index 000000000000..4f96aa73ee7d --- /dev/null +++ b/internal/command/testdata/state-store-unchanged/.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-store-unchanged/main.tf b/internal/command/testdata/state-store-unchanged/main.tf new file mode 100644 index 000000000000..d32e0d51615a --- /dev/null +++ b/internal/command/testdata/state-store-unchanged/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" {} + + value = "foobar" # matches backend state file + } +} diff --git a/internal/command/testdata/state-store-unset/.terraform.lock.hcl b/internal/command/testdata/state-store-unset/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/state-store-unset/.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-store-unset/.terraform/terraform.tfstate b/internal/command/testdata/state-store-unset/.terraform/terraform.tfstate index cfb4e3d72ade..b7e79f249766 100644 --- a/internal/command/testdata/state-store-unset/.terraform/terraform.tfstate +++ b/internal/command/testdata/state-store-unset/.terraform/terraform.tfstate @@ -10,9 +10,10 @@ "provider": { "version": "1.2.3", "source": "registry.terraform.io/hashicorp/test", - "config": {}, - "hash": 12345 + "config": { + "region": null + } }, - "hash": 12345 + "hash": 0 } } \ No newline at end of file diff --git a/internal/command/workdir/backend_state_test.go b/internal/command/workdir/backend_state_test.go index 1075154fd72c..1937000bf5cc 100644 --- a/internal/command/workdir/backend_state_test.go +++ b/internal/command/workdir/backend_state_test.go @@ -179,7 +179,7 @@ func TestEncodeBackendStateFile(t *testing.T) { Hash: 123, }, }, - Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": \"1.2.3\",\n \"source\": \"registry.terraform.io/my-org/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 12345\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), + Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": \"1.2.3\",\n \"source\": \"registry.terraform.io/my-org/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n }\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), }, "it's valid to record no version data when a builtin provider used for state store": { Input: &BackendStateFile{ @@ -190,7 +190,7 @@ func TestEncodeBackendStateFile(t *testing.T) { Hash: 123, }, }, - Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": null,\n \"source\": \"terraform.io/builtin/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 12345\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), + Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": null,\n \"source\": \"terraform.io/builtin/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n }\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), }, "it's valid to record no version data when a re-attached provider used for state store": { Input: &BackendStateFile{ @@ -215,7 +215,7 @@ func TestEncodeBackendStateFile(t *testing.T) { } }`, }, - Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": null,\n \"source\": \"registry.terraform.io/hashicorp/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 12345\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), + Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": null,\n \"source\": \"registry.terraform.io/hashicorp/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n }\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), }, "error when neither backend nor state_store config state are present": { Input: &BackendStateFile{}, diff --git a/internal/command/workdir/statestore_config_state.go b/internal/command/workdir/statestore_config_state.go index 2833f28f3002..020fe1a0324f 100644 --- a/internal/command/workdir/statestore_config_state.go +++ b/internal/command/workdir/statestore_config_state.go @@ -27,7 +27,7 @@ type StateStoreConfigState struct { Type string `json:"type"` // State store type name Provider *ProviderConfigState `json:"provider"` // Details about the state-storage provider ConfigRaw json.RawMessage `json:"config"` // state_store block raw config, barring provider details - Hash uint64 `json:"hash"` // Hash of the state_store block's configuration, excluding the provider block and any values supplied via methods other than config + Hash uint64 `json:"hash"` // Hash of the state_store block's configuration, including the nested provider block } // Empty returns true if there is no active state store. @@ -139,7 +139,6 @@ func (s *StateStoreConfigState) DeepCopy() *StateStoreConfigState { provider := &ProviderConfigState{ Version: s.Provider.Version, Source: s.Provider.Source, - Hash: s.Provider.Hash, } if s.Provider.ConfigRaw != nil { provider.ConfigRaw = make([]byte, len(s.Provider.ConfigRaw)) @@ -168,7 +167,6 @@ type ProviderConfigState struct { Version *version.Version `json:"version"` // The specific provider version used for the state store. Should be set using a getproviders.Version, etc. Source *tfaddr.Provider `json:"source"` // The FQN/fully-qualified name of the provider. ConfigRaw json.RawMessage `json:"config"` // state_store block raw config, barring provider details - Hash uint64 `json:"hash"` // Hash of the nested provider block's configuration, excluding any values supplied via methods other than config } // Empty returns true if there is no provider config state data. diff --git a/internal/command/workdir/testing.go b/internal/command/workdir/testing.go index c86859a2be82..0c449322d2b8 100644 --- a/internal/command/workdir/testing.go +++ b/internal/command/workdir/testing.go @@ -37,6 +37,5 @@ func getTestProviderState(t *testing.T, semVer, hostname, namespace, typeName, c Type: typeName, }, ConfigRaw: []byte(config), - Hash: 12345, } } diff --git a/internal/configs/state_store.go b/internal/configs/state_store.go index 25380244f052..8d5fb850fb73 100644 --- a/internal/configs/state_store.go +++ b/internal/configs/state_store.go @@ -6,6 +6,7 @@ package configs import ( "fmt" + version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" tfaddr "github.com/hashicorp/terraform-registry-address" @@ -130,9 +131,12 @@ func resolveStateStoreProviderType(requiredProviders map[string]*RequiredProvide } } -// Hash produces a hash value for the receiver that covers the type and the -// portions of the config that conform to the state_store schema. The provider -// block that is nested inside state_store is ignored. +// Hash produces a hash value for the receiver that covers: +// 1) the portions of the config that conform to the state_store schema. +// 2) the portions of the config that conform to the provider schema. +// 3) the state store type +// 4) the provider source +// 5) the provider version // // If the config does not conform to the schema then the result is not // meaningful for comparison since it will be based on an incomplete result. @@ -141,13 +145,14 @@ func resolveStateStoreProviderType(requiredProviders map[string]*RequiredProvide // for the purpose of hashing, so that an incomplete configuration can still // be hashed. Other errors, such as extraneous attributes, have no such special // case. -func (b *StateStore) Hash(stateStoreSchema *configschema.Block, providerSchema *configschema.Block) (stateStoreHash, providerHash int, diags tfdiags.Diagnostics) { +func (b *StateStore) Hash(stateStoreSchema *configschema.Block, providerSchema *configschema.Block, stateStoreProviderVersion *version.Version) (int, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics // 1. Prepare the state_store hash // The state store schema should not include a provider block or attr if _, exists := stateStoreSchema.Attributes["provider"]; exists { - return 0, 0, diags.Append(&hcl.Diagnostic{ + return 0, diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Protected argument name \"provider\" in state store schema", Detail: "Schemas for state stores cannot contain attributes or blocks called \"provider\", to avoid confusion with the provider block nested inside the state_store block. This is a bug in the provider used for state storage, which should be reported in the provider's own issue tracker.", @@ -155,7 +160,7 @@ func (b *StateStore) Hash(stateStoreSchema *configschema.Block, providerSchema * }) } if _, exists := stateStoreSchema.BlockTypes["provider"]; exists { - return 0, 0, diags.Append(&hcl.Diagnostic{ + return 0, diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Protected block name \"provider\" in state store schema", Detail: "Schemas for state stores cannot contain attributes or blocks called \"provider\", to avoid confusion with the provider block nested inside the state_store block. This is a bug in the provider used for state storage, which should be reported in the provider's own issue tracker.", @@ -178,18 +183,14 @@ func (b *StateStore) Hash(stateStoreSchema *configschema.Block, providerSchema * diags = diags.Append(diag) } if diags.HasErrors() { - return 0, 0, diags + return 0, diags } } - // We're on the happy path, so continue to get the hash + // We're on the happy path, but handle if we got a nil value above if ssVal == cty.NilVal { ssVal = cty.UnknownVal(schema.ImpliedType()) } - ssToHash := cty.TupleVal([]cty.Value{ - cty.StringVal(b.Type), - ssVal, - }) // 2. Prepare the provider hash schema = providerSchema.NoneRequired() @@ -197,15 +198,20 @@ func (b *StateStore) Hash(stateStoreSchema *configschema.Block, providerSchema * pVal, decodeDiags := hcldec.Decode(b.Provider.Config, spec, nil) if decodeDiags.HasErrors() { diags = diags.Append(decodeDiags) - return 0, 0, diags + return 0, diags } if pVal == cty.NilVal { pVal = cty.UnknownVal(schema.ImpliedType()) } - pToHash := cty.TupleVal([]cty.Value{ - cty.StringVal(b.Type), - pVal, - }) - return ssToHash.Hash(), pToHash.Hash(), diags + toHash := cty.TupleVal([]cty.Value{ + cty.StringVal(b.Type), // state store type + ssVal, // state store config + + cty.StringVal(b.ProviderAddr.String()), // provider source + cty.StringVal(stateStoreProviderVersion.String()), // provider version + cty.StringVal(b.Provider.Name), // provider name - this is directly parsed from the config, whereas provider source is added separately later after config is parsed. + pVal, // provider config + }) + return toHash.Hash(), diags } diff --git a/internal/configs/state_store_test.go b/internal/configs/state_store_test.go index 266c5e56a072..7fc9eb9f6b3b 100644 --- a/internal/configs/state_store_test.go +++ b/internal/configs/state_store_test.go @@ -7,8 +7,10 @@ import ( "strings" "testing" + version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" + tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/zclconf/go-cty/cty" ) @@ -17,28 +19,8 @@ import ( // and it requires calling code to remove the nested provider block from state_store config data. func TestStateStore_Hash(t *testing.T) { - // This test assumes a configuration like this, - // where the "fs" state store is implemented in - // the "foobar" provider: - // - // terraform { - // required_providers = { - // # entries would be here - // } - // state_store "foobar_fs" { - // # Nested provider block - // provider "foobar" { - // foobar = "foobar" - // } - - // # Attributes for configuring the state store - // path = "mystate.tfstate" - // workspace_dir = "foobar" - // } - // } - // Normally these schemas would come from a provider's GetProviderSchema data - stateStoreSchema := &configschema.Block{ + exampleStateStoreSchema := &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "path": { Type: cty.String, @@ -50,7 +32,7 @@ func TestStateStore_Hash(t *testing.T) { }, }, } - providerSchema := &configschema.Block{ + exampleProviderSchema := &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "foobar": { Type: cty.String, @@ -59,43 +41,84 @@ func TestStateStore_Hash(t *testing.T) { }, } + // These values are all coupled. + // The test case below asserts that given these inputs, the expected hash is returned. + exampleProviderVersion := version.Must(version.NewSemver("1.2.3")) + exampleProviderAddr := tfaddr.NewProvider(tfaddr.DefaultProviderRegistryHost, "hashicorp", "foobar") + exampleConfig := configBodyForTest(t, `state_store "foobar_fs" { + provider "foobar" { + foobar = "foobar" + } + path = "mystate.tfstate" + workspace_dir = "foobar" + }`) + exampleHash := 614398732 + t.Run("example happy path with all attrs set in the configuration", func(t *testing.T) { + // Construct a configs.StateStore for the test. + content, _, cfgDiags := exampleConfig.PartialContent(terraformBlockSchema) + if len(cfgDiags) > 0 { + t.Fatalf("unexpected diagnostics: %s", cfgDiags) + } + var ssDiags hcl.Diagnostics + s, ssDiags := decodeStateStoreBlock(content.Blocks.OfType("state_store")[0]) + if len(ssDiags) > 0 { + t.Fatalf("unexpected diagnostics: %s", ssDiags) + } + s.ProviderAddr = exampleProviderAddr + + // Test Hash method. + gotHash, diags := s.Hash(exampleStateStoreSchema, exampleProviderSchema, exampleProviderVersion) + if diags.HasErrors() { + t.Fatalf("unexpected error: %s", diags.Err()) + } + + if gotHash != exampleHash { + t.Fatalf("expected hash for state_store to be %d, but got %d", exampleHash, gotHash) + } + }) + + // Test cases each change a single input that affects the output hash + // Assertions check that the output hash doesn't match the hash above, following the changed input. cases := map[string]struct { - config hcl.Body - providerConfig hcl.Body - schema *configschema.Block - wantErrorString string - wantProviderHash int - wantStateStoreHash int + config hcl.Body + stateStoreSchema *configschema.Block + providerVersion *version.Version + providerAddr tfaddr.Provider }{ - "ignores the provider block in config data, as long as the schema doesn't include it": { - schema: stateStoreSchema, - config: configBodyForTest(t, `state_store "foo" { + "changing the state store type affects the hash value": { + config: configBodyForTest(t, `state_store "foobar_CHANGED_VALUE_HERE" { provider "foobar" { - foobar = "foobar" + foobar = "foobar" + } + path = "mystate.tfstate" + workspace_dir = "foobar" + }`), + }, + "changing the provider affects the hash value": { + providerAddr: tfaddr.NewProvider(tfaddr.DefaultProviderRegistryHost, "hashicorp", "different-provider"), + config: configBodyForTest(t, `state_store "different-provider_fs" { + provider "different-provider" { + foobar = "foobar" } path = "mystate.tfstate" workspace_dir = "foobar" }`), - providerConfig: configBodyForTest(t, `foobar = "foobar"`), - wantProviderHash: 2672365208, - wantStateStoreHash: 3037430836, + }, + "changing the provider version affects the hash value": { + providerVersion: version.Must(version.NewSemver("9.9.9")), }, "tolerates empty config block for the provider even when schema has Required field(s)": { - schema: stateStoreSchema, - config: configBodyForTest(t, `state_store "foo" { + config: configBodyForTest(t, `state_store "foobar_fs" { provider "foobar" { # required field "foobar" is missing } path = "mystate.tfstate" workspace_dir = "foobar" }`), - providerConfig: hcl.EmptyBody(), - wantProviderHash: 2911589008, - wantStateStoreHash: 3037430836, }, "tolerates missing Required field(s) in state_store config": { - schema: stateStoreSchema, - config: configBodyForTest(t, `state_store "foo" { + stateStoreSchema: exampleStateStoreSchema, + config: configBodyForTest(t, `state_store "foobar_fs" { provider "foobar" { foobar = "foobar" } @@ -103,13 +126,94 @@ func TestStateStore_Hash(t *testing.T) { # required field "path" is missing workspace_dir = "foobar" }`), - providerConfig: configBodyForTest(t, `foobar = "foobar"`), - wantProviderHash: 2672365208, - wantStateStoreHash: 3453024478, }, - "returns errors when the config contains non-provider things that aren't in the schema": { - schema: stateStoreSchema, - config: configBodyForTest(t, `state_store "foo" { + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // If a test case doesn't set an override for these inputs, + // instead use a default value from the example above. + var config hcl.Body + var schema *configschema.Block + var providerVersion *version.Version + var providerAddr tfaddr.Provider + if tc.config == nil { + config = exampleConfig + } else { + config = tc.config + } + if tc.stateStoreSchema == nil { + schema = exampleStateStoreSchema + } else { + schema = tc.stateStoreSchema + } + if tc.providerVersion == nil { + providerVersion = exampleProviderVersion + } else { + providerVersion = tc.providerVersion + } + if tc.providerAddr.IsZero() { + providerAddr = exampleProviderAddr + } else { + providerAddr = tc.providerAddr + } + + // Construct a configs.StateStore for the test. + content, _, cfgDiags := config.PartialContent(terraformBlockSchema) + if len(cfgDiags) > 0 { + t.Fatalf("unexpected diagnostics: %s", cfgDiags) + } + var ssDiags hcl.Diagnostics + s, ssDiags := decodeStateStoreBlock(content.Blocks.OfType("state_store")[0]) + if len(ssDiags) > 0 { + t.Fatalf("unexpected diagnostics: %s", ssDiags) + } + s.ProviderAddr = providerAddr + + // Test Hash method. + gotHash, diags := s.Hash(schema, exampleProviderSchema, providerVersion) + if diags.HasErrors() { + t.Fatalf("unexpected error: %s", diags.Err()) + } + if gotHash == exampleHash { + t.Fatal("expected hash for state_store to be different from the example due to a changed input, but it matched.") + } + }) + } +} + +func TestStateStore_Hash_errorConditions(t *testing.T) { + // Normally these schemas would come from a provider's GetProviderSchema data + exampleStateStoreSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "path": { + Type: cty.String, + Required: true, + }, + "workspace_dir": { + Type: cty.String, + Optional: true, + }, + }, + } + exampleProviderSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foobar": { + Type: cty.String, + Required: true, + }, + }, + } + + // Cases where an error would occur + cases := map[string]struct { + config hcl.Body + stateStoreSchema *configschema.Block + wantErrorString string + }{ + "returns errors when the state_store config doesn't match the schema": { + stateStoreSchema: exampleStateStoreSchema, + config: configBodyForTest(t, `state_store "foobar_fs" { provider "foobar" { foobar = "foobar" } @@ -120,11 +224,25 @@ func TestStateStore_Hash(t *testing.T) { path = "mystate.tfstate" workspace_dir = "foobar" }`), - providerConfig: configBodyForTest(t, `foobar = "foobar"`), wantErrorString: "Unsupported argument", }, - "returns an error if the schema includes a provider block": { - schema: &configschema.Block{ + "returns errors when the provider config doesn't match the schema": { + stateStoreSchema: exampleStateStoreSchema, + config: configBodyForTest(t, `state_store "foobar_fs" { + provider "foobar" { + foobar = "foobar" + unexpected_attr = "foobar" + unexpected_block { + foobar = "foobar" + } + } + path = "mystate.tfstate" + workspace_dir = "foobar" + }`), + wantErrorString: "Unsupported argument", + }, + "returns an error if the state_store schema includes a provider block": { + stateStoreSchema: &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ "provider": { Block: configschema.Block{ @@ -139,18 +257,17 @@ func TestStateStore_Hash(t *testing.T) { }, }, }, - config: configBodyForTest(t, `state_store "foo" { + config: configBodyForTest(t, `state_store "foobar_fs" { provider "foobar" { foobar = "foobar" } path = "mystate.tfstate" workspace_dir = "foobar" }`), - providerConfig: configBodyForTest(t, `foobar = "foobar"`), wantErrorString: `Protected block name "provider" in state store schema`, }, - "returns an error if the schema includes a provider attribute": { - schema: &configschema.Block{ + "returns an error if the state_store schema includes a provider attribute": { + stateStoreSchema: &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "provider": { Type: cty.String, @@ -158,20 +275,20 @@ func TestStateStore_Hash(t *testing.T) { }, }, }, - config: configBodyForTest(t, `state_store "foo" { + config: configBodyForTest(t, `state_store "foobar_fs" { provider "foobar" { foobar = "foobar" } path = "mystate.tfstate" workspace_dir = "foobar" }`), - providerConfig: configBodyForTest(t, `foobar = "foobar"`), wantErrorString: `Protected argument name "provider" in state store schema`, }, } for tn, tc := range cases { t.Run(tn, func(t *testing.T) { + // Construct a configs.StateStore for the test. content, _, cfgDiags := tc.config.PartialContent(terraformBlockSchema) if len(cfgDiags) > 0 { t.Fatalf("unexpected diagnostics: %s", cfgDiags) @@ -181,95 +298,20 @@ func TestStateStore_Hash(t *testing.T) { if len(ssDiags) > 0 { t.Fatalf("unexpected diagnostics: %s", ssDiags) } + s.ProviderAddr = tfaddr.NewProvider(tfaddr.DefaultProviderRegistryHost, "hashicorp", "foobar") - ssHash, pHash, diags := s.Hash(tc.schema, providerSchema) - if diags.HasErrors() { - if tc.wantErrorString == "" { - t.Fatalf("unexpected error: %s", diags.Err()) - } - if !strings.Contains(diags.Err().Error(), tc.wantErrorString) { - t.Fatalf("expected %q to be in the returned error string but it's missing: %q", tc.wantErrorString, diags.Err()) - } - - return // early return if testing an error case - } - - if !diags.HasErrors() && tc.wantErrorString != "" { - t.Fatal("expected an error when generating a hash, but got none") - } - - if ssHash != tc.wantStateStoreHash { - t.Fatalf("expected hash for state_store to be %d, but got %d", tc.wantStateStoreHash, ssHash) + // Test Hash method. + _, diags := s.Hash(tc.stateStoreSchema, exampleProviderSchema, version.Must(version.NewSemver("1.2.3"))) + if !diags.HasErrors() { + t.Fatal("expected error but got none") } - if pHash != tc.wantProviderHash { - t.Fatalf("expected hash for provider to be %d, but got %d", tc.wantProviderHash, pHash) + if !strings.Contains(diags.Err().Error(), tc.wantErrorString) { + t.Fatalf("expected error to contain %q but got: %s", tc.wantErrorString, diags.Err()) } }) } } -func TestStateStore_checkStateStoreHashUnaffectedByProviderBlock(t *testing.T) { - - // Normally these schemas would come from a provider's GetProviderSchema data - stateStoreSchema := &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "path": { - Type: cty.String, - Required: true, - }, - "workspace_dir": { - Type: cty.String, - Optional: true, - }, - }, - } - providerSchema := &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foobar": { - Type: cty.String, - Required: true, - }, - }, - } - providerConfig := configBodyForTest(t, `foobar = "foobar"`) - - // Make two StateStores: - // 1) Has provider block in the main Config value, as well as matching data in Provider.Config - // 2) Doesn't have provider block in the main Config value, has config in Provider.Config - - s1 := StateStore{ - Config: configBodyForTest(t, `state_store "foo" { - provider "foobar" { - foobar = "foobar" - } - path = "mystate.tfstate" - workspace_dir = "foobar" - }`), - Provider: &Provider{ - Config: providerConfig, - }, - } - s2 := StateStore{ - Config: configBodyForTest(t, `state_store "foo" { - # No provider block here - - path = "mystate.tfstate" - workspace_dir = "foobar" - }`), - Provider: &Provider{ - Config: providerConfig, - }, - } - - s1StoreHash, _, _ := s1.Hash(stateStoreSchema, providerSchema) - s2StoreHash, _, _ := s2.Hash(stateStoreSchema, providerSchema) - - if s1StoreHash != s2StoreHash { - t.Fatalf("expected state_store block hashes to match, as hashing logic should ignore presence of provider block. Got s1 %d, s2 %d", s1StoreHash, s2StoreHash) - } - -} - func configBodyForTest(t *testing.T, config string) hcl.Body { t.Helper() f, diags := hclsyntax.ParseConfig([]byte(config), "", hcl.Pos{Line: 1, Column: 1})