PSS: Enable state store configuration change (#38153)

This commit is contained in:
Radek Simko 2026-02-26 15:00:13 +00:00 committed by GitHub
parent c37cf1c0f5
commit cd9257cd53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 696 additions and 71 deletions

View file

@ -4155,7 +4155,7 @@ func TestInit_stateStore_configChanges(t *testing.T) {
}
})
t.Run("handling changed state store config is currently unimplemented", func(t *testing.T) {
t.Run("handling changed state store config without -migrate-state flag", func(t *testing.T) {
// Create a temporary working directory with state store configuration
// that doesn't match the backend state file
td := t.TempDir()
@ -4189,30 +4189,34 @@ func TestInit_stateStore_configChanges(t *testing.T) {
args := []string{
"-enable-pluggable-state-storage-experiment=true",
// missing -migrate-state flag
}
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())
if code == 0 {
t.Fatalf("expected non-zero 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"
expectedMsg := "Error: State store configuration changed"
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) {
t.Run("handling changed state store config", 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)
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.
// The previous init implied by this test scenario would have created the default workspace.
mockProvider.MockStates = map[string]any{
backend.DefaultStateName: []byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`),
}
mockProviderAddress := addrs.NewDefaultProvider("test")
providerSource, close := newMockProviderSource(t, map[string][]string{
"hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture
@ -4238,22 +4242,94 @@ func TestInit_stateStore_configChanges(t *testing.T) {
args := []string{
"-enable-pluggable-state-storage-experiment=true",
"-force-copy",
}
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())
if code != 0 {
t.Fatalf("expected 0 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"
expectedMsg := "Terraform has been successfully initialized!"
if !strings.Contains(output, expectedMsg) {
t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output)
}
expectedReason := "State store \"test_store\" (hashicorp/test) configuration changed"
if !strings.Contains(output, expectedReason) {
t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output)
}
// check state remains accessible after migration
if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists {
t.Fatalf("expected the default workspace to exist after migration, but it is missing: %#v", mockProvider.MockStates)
}
})
t.Run("handling changed state store type in the same provider is currently unimplemented", func(t *testing.T) {
t.Run("handling changed state store provider config", 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()
// The previous init implied by this test scenario would have created the default workspace.
mockProvider.MockStates = map[string]any{
backend.DefaultStateName: []byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`),
}
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",
"-force-copy",
}
code := c.Run(args)
testOutput := done(t)
if code != 0 {
t.Fatalf("expected 0 exit code, got %d, output: \n%s", code, testOutput.All())
}
// Check output
output := testOutput.All()
expectedMsg := "Terraform has been successfully initialized!"
if !strings.Contains(output, expectedMsg) {
t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output)
}
expectedReason := "State store provider \"test\" (hashicorp/test) configuration changed"
if !strings.Contains(output, expectedReason) {
t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output)
}
// check state remains accessible after migration
if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists {
t.Fatal("expected the default workspace to exist after migration, but it is missing")
}
})
t.Run("handling changed state store type in the same provider", func(t *testing.T) {
// Create a temporary working directory with state store configuration
// that doesn't match the backend state file
td := t.TempDir()
@ -4261,6 +4337,10 @@ func TestInit_stateStore_configChanges(t *testing.T) {
t.Chdir(td)
mockProvider := mockPluggableStateStorageProvider()
// The previous init implied by this test scenario would have created the default workspace.
mockProvider.MockStates = map[string]any{
backend.DefaultStateName: []byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`),
}
storeName := "test_store"
otherStoreName := "test_otherstore"
// Make the provider report that it contains a 2nd storage implementation with the above name
@ -4290,22 +4370,32 @@ func TestInit_stateStore_configChanges(t *testing.T) {
args := []string{
"-enable-pluggable-state-storage-experiment=true",
"-force-copy",
}
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())
if code != 0 {
t.Fatalf("expected 0 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"
expectedMsg := "Terraform has been successfully initialized!"
if !strings.Contains(output, expectedMsg) {
t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output)
}
expectedReason := "State store type changed from \"test_store\" to \"test_otherstore\""
if !strings.Contains(output, expectedReason) {
t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output)
}
// check state remains accessible after migration
if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists {
t.Fatal("expected the default workspace to exist after migration, but it is missing")
}
})
t.Run("handling changing the provider used for state storage is currently unimplemented", func(t *testing.T) {
t.Run("handling changing the provider used for state storage", func(t *testing.T) {
// Create a temporary working directory with state store configuration
// that doesn't match the backend state file
td := t.TempDir()
@ -4313,7 +4403,10 @@ func TestInit_stateStore_configChanges(t *testing.T) {
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.
// The previous init implied by this test scenario would have created the default workspace.
mockProvider.MockStates = map[string]any{
backend.DefaultStateName: []byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`),
}
// Make a mock that implies its name is test2 based on returned schemas
mockProvider2 := mockPluggableStateStorageProvider()
@ -4348,19 +4441,29 @@ func TestInit_stateStore_configChanges(t *testing.T) {
args := []string{
"-enable-pluggable-state-storage-experiment=true",
"-force-copy",
}
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())
if code != 0 {
t.Fatalf("expected 0 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"
expectedMsg := "Terraform has been successfully initialized!"
if !strings.Contains(output, expectedMsg) {
t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output)
}
expectedReason := "State store provider changed from hashicorp/test to hashicorp/test2"
if !strings.Contains(output, expectedReason) {
t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output)
}
// check state remains accessible after migration
if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists {
t.Fatal("expected the default workspace to exist after migration, but it is missing")
}
})
}
@ -4370,7 +4473,7 @@ func TestInit_stateStore_configChanges(t *testing.T) {
// 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) {
t.Run("handling upgrading the provider used for state storage", func(t *testing.T) {
// Create a temporary working directory with state store configuration
// that doesn't match the backend state file
td := t.TempDir()
@ -4378,6 +4481,10 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) {
t.Chdir(td)
mockProvider := mockPluggableStateStorageProvider()
// The previous init implied by this test scenario would have created the default workspace.
mockProvider.MockStates = map[string]any{
backend.DefaultStateName: []byte(`{"version":4,"terraform_version":"1.15.0","serial":1,"lineage":"91adaece-23b3-7bce-0695-5aea537d2fef","outputs":{"test":{"value":"test","type":"string"}},"resources":[],"check_results":null}`),
}
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
@ -4403,23 +4510,147 @@ func TestInit_stateStore_providerUpgrade(t *testing.T) {
args := []string{
"-enable-pluggable-state-storage-experiment=true",
"-migrate-state=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())
if code != 0 {
t.Fatalf("expected 0 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"
expectedMsg := "Terraform has been successfully initialized!"
if !strings.Contains(output, expectedMsg) {
t.Fatalf("expected output to include %q, but got':\n %s", expectedMsg, output)
}
expectedReason := "State store provider \"test\" (hashicorp/test) version changed from 1.2.3 to 9.9.9"
if !strings.Contains(output, expectedReason) {
t.Fatalf("expected output to include reason %q, but got':\n %s", expectedReason, output)
}
// check state remains accessible after migration
if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists {
t.Fatal("expected the default workspace to exist after migration, but it is missing")
}
})
}
// Test a scenario where the configuration changes but the -backend-config CLI flags compensate for those changes
// to result in the state store being configured in the same way. In this scenario the user isn't prompted to migrate
// state but the backend state file is updated with a new hash to reflect the new configuration's values.
func TestInit_stateStore_backendConfigFlagNoMigrate(t *testing.T) {
// Create a temporary working directory and copy in test fixtures
td := t.TempDir()
testCopyDir(t, testFixturePath("init-state-store"), td)
t.Chdir(td)
mockProvider := mockPluggableStateStorageProvider()
// Make the state store's value attribute optional in this test.
mockProvider.GetProviderSchemaResponse.StateStores["test_store"].Body.Attributes["value"].Required = false
mockProviderAddress := addrs.NewDefaultProvider("test")
providerSource, close := newMockProviderSource(t, map[string][]string{
"hashicorp/test": {"1.2.3"}, // Matches provider version in backend state file fixture
})
t.Cleanup(close)
var originalStateStoreConfigHash uint64
{
log.Printf("[TRACE] TestInit_stateStore_unset: beginning first init")
ui := cli.NewMockUi()
view, done := testView(t)
c := &InitCommand{
Meta: Meta{
testingOverrides: &testingOverrides{
Providers: map[addrs.Provider]providers.Factory{
mockProviderAddress: providers.FactoryFixed(mockProvider),
},
},
ProviderSource: providerSource,
Ui: ui,
View: view,
AllowExperimentalFeatures: true,
},
}
// Init
args := []string{
"-enable-pluggable-state-storage-experiment=true",
}
code := c.Run(args)
testOutput := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", testOutput.All())
}
log.Printf("[TRACE] TestInit_stateStore_unset: first init complete")
t.Logf("First run output:\n%s", testOutput.Stdout())
t.Logf("First run errors:\n%s", testOutput.Stderr())
s := testDataStateRead(t, filepath.Join(DefaultDataDir, DefaultStateFilename))
if s.StateStore.Empty() {
t.Fatal("should have StateStore config")
}
// Store this for comparison after the 2nd init
originalStateStoreConfigHash = s.StateStore.Hash
}
{
log.Printf("[TRACE] TestInit_stateStore_unset: beginning second init with changed config but compensating CLI flags")
// Remove `value` attribute from config
cfg := `terraform {
state_store "test_store" {
provider "test" {}
# value attr removed here
}
}`
if err := os.WriteFile("main.tf", []byte(cfg), 0644); err != nil {
t.Fatalf("err: %s", err)
}
ui := cli.NewMockUi()
view, done := testView(t)
c := &InitCommand{
Meta: Meta{
testingOverrides: &testingOverrides{
Providers: map[addrs.Provider]providers.Factory{
mockProviderAddress: providers.FactoryFixed(mockProvider),
},
},
ProviderSource: providerSource,
Ui: ui,
View: view,
AllowExperimentalFeatures: true,
},
}
args := []string{
"-enable-pluggable-state-storage-experiment=true",
"-backend-config=value=foobar", // value = foobar, matches the line removed from config
}
code := c.Run(args)
testOutput := done(t)
if code != 0 {
t.Fatalf("Terraform either experienced an unexpected error, or suggested a state migration when this test scenario should not include migrations: \n%s", testOutput.All())
}
log.Printf("[TRACE] TestInit_stateStore_unset: second init complete")
t.Logf("Second run output:\n%s", testOutput.Stdout())
t.Logf("Second run errors:\n%s", testOutput.Stderr())
s := testDataStateRead(t, filepath.Join(DefaultDataDir, DefaultStateFilename))
if s.StateStore.Empty() {
t.Fatal("should have StateStore config")
}
if s.StateStore.Hash == originalStateStoreConfigHash {
t.Fatal("expected second init to update the state_store config hash in the backend state file, but it did not")
}
}
}
func TestInit_stateStore_unset(t *testing.T) {
// Create a temporary working directory and copy in test fixtures
td := t.TempDir()
@ -6085,6 +6316,7 @@ func expectedPackageInstallPath(name, version string, exe bool) string {
))
}
// TODO: introduce pssName as argument here to aid testing migrations
func mockPluggableStateStorageProvider() *testing_provider.MockProvider {
// Create a mock provider to use for PSS
// Get mock provider factory to be used during init
@ -6127,6 +6359,12 @@ func mockPluggableStateStorageProvider() *testing_provider.MockProvider {
},
},
}
mock.GetStatesFn = func(req providers.GetStatesRequest) providers.GetStatesResponse {
states := slices.Sorted(maps.Keys(mock.MockStates))
return providers.GetStatesResponse{
States: states,
}
}
mock.ConfigureStateStoreFn = func(req providers.ConfigureStateStoreRequest) providers.ConfigureStateStoreResponse {
return providers.ConfigureStateStoreResponse{
Capabilities: providers.StateStoreServerCapabilities{

View file

@ -1027,9 +1027,10 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
s.StateStore.Provider.Source,
)
initReason := fmt.Sprintf("Unsetting the previously set state store %q", s.StateStore.Type)
if !opts.Init {
diags = diags.Append(errStateStoreInitDiag(initReason))
diags = diags.Append(errStateStoreInitDiag(&ssInitReason{
Reason: fmt.Sprintf("Unsetting the previously set state store %q", s.StateStore.Type),
}))
return nil, diags
}
@ -1076,12 +1077,15 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
)
if !opts.Init {
initReason := fmt.Sprintf("Initial configuration of the requested state_store %q in provider %s (%q)",
reason := fmt.Sprintf("Initial configuration of a state store %q in provider %s (%q)",
stateStoreConfig.Type,
stateStoreConfig.Provider.Name,
stateStoreConfig.ProviderAddr,
)
diags = diags.Append(errStateStoreInitDiag(initReason))
diags = diags.Append(errStateStoreInitDiag(&ssInitReason{
Reason: reason,
Subject: stateStoreConfig.DeclRange.Ptr(),
}))
return nil, diags
}
@ -1213,15 +1217,8 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
// Potentially changing a state store configuration
case backendConfig == nil && s.Backend.Empty() &&
stateStoreConfig != nil && !s.StateStore.Empty():
// When implemented, this will need to handle multiple scenarios like:
// > Changing to using a different provider for PSS.
// > Changing to using a different version of the same provider for PSS.
// >>>> Navigating state upgrades that do not force an explicit migration &&
// identifying when migration is required.
// > Changing to using a different store in the same version of the provider.
// > Changing how the provider is configured.
// > Changing how the store is configured.
// > Allowing values to be moved between partial overrides and config
// TODO: Navigating state upgrades that do not force an explicit migration &&
// identifying when migration is required.
// 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.
@ -1240,14 +1237,56 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
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",
Detail: "Changing a state store configuration is not implemented yet",
})
// If our configuration (the result of both the literal configuration and given
// -backend-config options) is the same, then we're just initializing a previously
// configured state store. The literal configuration may differ, however, so while we
// don't need to migrate, we update the state store cache hash value.
if !m.stateStoreConfigNeedsMigration(stateStoreConfig, s.StateStore, opts) {
log.Printf("[TRACE] Meta.Backend: using already-initialized %q state store configuration", stateStoreConfig.Type)
savedStateStore, moreDiags := m.savedStateStore(sMgr)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return nil, diags
}
// It's possible for a state store 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.updateSavedStateStoreHash(cHash, sMgr)
if moreDiags.HasErrors() {
return nil, diags
}
// 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
}
initReason, ssDiags := m.determineStateStoreInitReason(s.StateStore, stateStoreConfig, opts.Locks)
diags = diags.Append(ssDiags)
if ssDiags.HasErrors() {
return nil, diags
}
if !opts.Init {
// user ran another cmd that is not init but they are required to initialize
diags = diags.Append(errStateStoreInitDiag(initReason))
return nil, diags
}
log.Printf("[WARN] state store has changed since last init: %q", initReason.Reason)
if !m.migrateState {
diags = diags.Append(migrateOrReconfigStateStoreDiag)
return nil, diags
}
return m.stateStore_changed(stateStoreConfig, cHash, sMgr, opts, initReason)
default:
diags = diags.Append(fmt.Errorf(
@ -1302,6 +1341,109 @@ func (m *Meta) determineInitReason(previousBackendType string, currentBackendTyp
return diags
}
// determineStateStoreInitReason determines the reason for the state store configuration change.
//
// When a reason cannot be determined and the underlying problem
// is likely to be addressed by the migration, a warning diagnostic is returned,
// otherwise an error diagnostic is returned.
func (m *Meta) determineStateStoreInitReason(cfgState *workdir.StateStoreConfigState, cfg *configs.StateStore, pLocks *depsfile.Locks) (*ssInitReason, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
if cfgState == nil || cfgState.Empty() {
return &ssInitReason{
Reason: fmt.Sprintf("State store %q (%s) not initialised",
cfg.Type, cfg.ProviderAddr.ForDisplay()),
Subject: cfg.DeclRange.Ptr(),
}, diags
}
if !cfg.ProviderAddr.Equals(*cfgState.Provider.Source) {
return &ssInitReason{
Reason: fmt.Sprintf("State store provider changed from %s to %s",
cfgState.Provider.Source.ForDisplay(), cfg.ProviderAddr.ForDisplay()),
Subject: cfg.Provider.DeclRange.Ptr(),
}, diags
}
pLock := pLocks.Provider(cfg.ProviderAddr)
lockVersion, err := providerreqs.GoVersionFromVersion(pLock.Version())
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
"Unable to determine version of the state store provider",
fmt.Sprintf("Failed to parse version of %s from the lock file: %s", cfg.ProviderAddr.ForDisplay(), err),
))
return nil, diags
}
if !lockVersion.Equal(cfgState.Provider.Version) {
return &ssInitReason{
Reason: fmt.Sprintf("State store provider %q (%s) version changed from %s to %s",
cfg.Provider.Name, cfg.ProviderAddr.ForDisplay(),
cfgState.Provider.Version, lockVersion),
}, diags
}
if cfgState.Type != cfg.Type {
return &ssInitReason{
Reason: fmt.Sprintf("State store type changed from %q to %q",
cfgState.Type, cfg.Type),
Subject: cfg.TypeRange.Ptr(),
}, diags
}
// We need the state store schema to do our comparisons here.
ssBackend, ssCfgVal, pCfgVal, ssDiags := m.stateStoreInitFromConfig(cfg, pLocks)
if ssDiags.HasErrors() {
return nil, ssDiags
}
// change of provider configuration
cachedProviderVal, err := cfgState.Provider.Config(ssBackend.ProviderSchema())
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
"Unable to decode state_store provider configuration",
fmt.Sprintf("Failed to decode configuration of the provider %q (%s): %s",
cfg.Provider.Name, cfg.ProviderAddr.ForDisplay(), err),
))
return nil, diags
}
if !cachedProviderVal.RawEquals(pCfgVal) {
return &ssInitReason{
Reason: fmt.Sprintf("State store provider %q (%s) configuration changed",
cfg.Provider.Name, cfg.ProviderAddr.ForDisplay()),
Subject: cfg.Provider.DeclRange.Ptr(),
}, diags
}
// change of state_store configuration
cachedSsVal, err := cfgState.Config(ssBackend.ConfigSchema())
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
"Unable to decode state_store configuration",
fmt.Sprintf("Failed to decode configuration of state_store %q: %s",
cfg.Type, err),
))
return nil, diags
}
if !cachedSsVal.RawEquals(ssCfgVal) {
return &ssInitReason{
Reason: fmt.Sprintf("State store %q (%s) configuration changed",
cfg.Type, cfg.ProviderAddr.ForDisplay()),
Subject: cfg.DeclRange.Ptr(),
}, diags
}
// The above conditions should have covered all possible reasons
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Unable to determine state store init reason",
"This is a bug in Terraform and should be reported",
))
return nil, diags
}
// backendFromState returns the initialized (not configured) backend directly
// from the backend state. This should be used only when a user runs
// `terraform init -backend=false`. This function returns a local backend if
@ -1863,6 +2005,22 @@ func (m *Meta) updateSavedBackendHash(cHash int, sMgr *clistate.LocalState) tfdi
return diags
}
func (m *Meta) updateSavedStateStoreHash(cHash int, sMgr *clistate.LocalState) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
s := sMgr.State()
if s.StateStore.Hash != uint64(cHash) {
s.StateStore.Hash = uint64(cHash)
if err := sMgr.WriteState(s); err != nil {
diags = diags.Append(errStateStoreWriteSavedDiag(err))
}
// No need to call PersistState as it's a no-op
}
return diags
}
// backend returns an operations backend that may use a backend, cloud, or state_store block for state storage.
// Based on the supplied config, it prepares arguments to pass into (Meta).Backend, which returns the operations backend.
//
@ -2061,11 +2219,7 @@ func (m *Meta) backend_to_stateStore(bcs *workdir.BackendConfigState, sMgr *clis
return nil, diags
}
// We need to briefly convert away from backend.Backend interface to use the method
// for accessing the provider schema. In this method we _always_ expect the concrete value
// to be backendPluggable.Pluggable.
plug := ssBackend.(*backendPluggable.Pluggable)
err = s.StateStore.Provider.SetConfig(providerConfigVal, plug.ProviderSchema())
err = s.StateStore.Provider.SetConfig(providerConfigVal, ssBackend.ProviderSchema())
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to set state store provider configuration: %w", err))
return nil, diags
@ -2295,11 +2449,7 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backend
return nil, diags
}
// We need to briefly convert away from backend.Backend interface to use the method
// for accessing the provider schema. In this method we _always_ expect the concrete value
// to be backendPluggable.Pluggable.
plug := b.(*backendPluggable.Pluggable)
err = s.StateStore.Provider.SetConfig(providerConfigVal, plug.ProviderSchema())
err = s.StateStore.Provider.SetConfig(providerConfigVal, b.ProviderSchema())
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to set state store provider configuration: %w", err))
return nil, diags
@ -2418,6 +2568,205 @@ func (m *Meta) stateStore_to_backend(ssSMgr *clistate.LocalState, dstBackendType
return dstBackend, diags
}
// stateStoreConfigNeedsMigration returns true if migration might be required to
// move from the configured state store to the given cached state store config.
//
// This must be called with the synthetic *configs.StateStore that results from
// merging in any command-line options for correct behavior.
//
// If either the given configuration or cached configuration are invalid then
// this function will conservatively assume that migration is required,
// expecting that the migration code will subsequently deal with the same
// errors.
func (m *Meta) stateStoreConfigNeedsMigration(cfg *configs.StateStore, cfgState *workdir.StateStoreConfigState, opts *BackendOpts) bool {
if cfgState == nil || cfgState.Empty() {
log.Print("[TRACE] stateStoreConfigNeedsMigration: no cached config, so migration is required")
return true
}
if !cfg.ProviderAddr.Equals(*cfgState.Provider.Source) {
log.Printf("[TRACE] stateStoreConfigNeedsMigration: provider changed from %q to %q, so migration is required", cfgState.Provider.Source, cfg.ProviderAddr)
return true
}
pLock := opts.Locks.Provider(cfg.ProviderAddr)
pVersion, err := providerreqs.GoVersionFromVersion(pLock.Version())
if err != nil {
log.Printf("[TRACE] stateStoreConfigNeedsMigration: unable to determine provider version (%s), so migration is required", err)
return true // let the migration codepath deal with the error
}
if !pVersion.Equal(cfgState.Provider.Version) {
log.Printf("[TRACE] stateStoreConfigNeedsMigration: provider version changed from %q to %q, so migration is required", cfgState.Provider.Version, pVersion)
return true
}
if cfg.Type != cfgState.Type {
log.Printf("[TRACE] stateStoreConfigNeedsMigration: type changed from %q to %q, so migration is required", cfgState.Type, cfg.Type)
return true
}
// We need the state store schema to do our comparisons here.
ssBackend, ssCfgVal, pCfgVal, ssDiags := m.stateStoreInitFromConfig(cfg, opts.Locks)
if ssDiags.HasErrors() {
log.Printf("[ERROR] Unable to initialise state store: %s", ssDiags)
return true
}
// change of provider configuration
cachedProviderVal, err := cfgState.Provider.Config(ssBackend.ProviderSchema())
if err != nil {
log.Printf("[ERROR] Unable to decode cached provider configuration: %s", err)
return true
}
if !cachedProviderVal.RawEquals(pCfgVal) {
log.Printf("[TRACE] stateStoreConfigNeedsMigration: provider configuration changed, so migration is needed")
return true
}
// change of state_store configuration
cachedSsVal, err := cfgState.Config(ssBackend.ConfigSchema())
if err != nil {
log.Printf("[TRACE] stateStoreConfigNeedsMigration: failed to decode cached config; migration codepath must handle problem: %s", err)
return true // let the migration codepath deal with the error
}
// If we get all the way down here then it's the exact equality of the
// two decoded values that decides our outcome. It's safe to use RawEquals
// here (rather than Equals) because we know that unknown values can
// never appear in backend configurations.
if cachedSsVal.RawEquals(ssCfgVal) {
log.Print("[TRACE] stateStoreConfigNeedsMigration: given configuration matches cached configuration, so no migration is required")
return false
}
log.Print("[TRACE] stateStoreConfigNeedsMigration: configuration values have changed, so migration is required")
return true
}
func (m *Meta) stateStore_changed(cfg *configs.StateStore, cfgHash int, sMgr *clistate.LocalState, opts *BackendOpts, initReason *ssInitReason) (backend.Backend, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
vt := arguments.ViewJSON
// Set default viewtype if none was set as the StateLocker needs to know exactly
// what viewType we want to have.
if opts == nil || opts.ViewType != vt {
vt = arguments.ViewHuman
}
// Get the destination state store
dstB, storeConfigVal, providerConfigVal, moreDiags := m.stateStoreInitFromConfig(cfg, opts.Locks)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return nil, diags
}
// Grab the source state store
srcB, srcBDiags := m.savedStateStore(sMgr)
diags = diags.Append(srcBDiags)
if srcBDiags.HasErrors() {
return nil, diags
}
// Get the old state
s := sMgr.State()
view := views.NewInit(vt, m.View)
view.Output(views.StateStoreMigrationMessage,
s.StateStore.Type, s.StateStore.Provider.Source.ForDisplay(),
cfg.Type, cfg.ProviderAddr.ForDisplay(),
initReason.Reason)
// Perform the migration
err := m.backendMigrateState(&backendMigrateOpts{
SourceType: s.StateStore.Type,
DestinationType: cfg.Type,
Source: srcB,
Destination: dstB,
ViewType: vt,
})
if err != nil {
diags = diags.Append(err)
return nil, diags
}
if m.stateLock {
view := views.NewStateLocker(vt, m.View)
stateLocker := clistate.NewLocker(m.stateLockTimeout, view)
if err := stateLocker.Lock(sMgr, "state store from plan"); err != nil {
diags = diags.Append(fmt.Errorf("Error locking state: %s", err))
return nil, diags
}
defer stateLocker.Unlock()
}
var pVersion *version.Version // This will remain nil for builtin providers or unmanaged providers.
if cfg.ProviderAddr.IsBuiltIn() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "State storage is using a builtin provider",
Detail: "Terraform is using a builtin provider for initializing state storage. Terraform will be less able to detect when state migrations are required in future init commands.",
})
} else {
isReattached, err := reattach.IsProviderReattached(cfg.ProviderAddr, os.Getenv("TF_REATTACH_PROVIDERS"))
if err != nil {
diags = diags.Append(fmt.Errorf("Unable to determine if state storage provider is reattached while initializing state store for the first time. This is a bug in Terraform and should be reported: %w", err))
return nil, diags
}
if isReattached {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "State storage provider is not managed by Terraform",
Detail: "Terraform is using a provider supplied via TF_REATTACH_PROVIDERS for initializing state storage. Terraform will be less able to detect when state migrations are required in future init commands.",
})
} else {
// The provider is not built in and is being managed by Terraform
// This is the most common scenario, by far.
var vDiags tfdiags.Diagnostics
pVersion, vDiags = getStateStorageProviderVersion(cfg, opts.Locks)
diags = diags.Append(vDiags)
if vDiags.HasErrors() {
return nil, diags
}
}
}
// Update the state to the new configuration
s = sMgr.State()
if s == nil {
s = workdir.NewBackendStateFile()
}
s.StateStore = &workdir.StateStoreConfigState{
Type: cfg.Type,
Hash: uint64(cfgHash),
Provider: &workdir.ProviderConfigState{
Source: &cfg.ProviderAddr,
Version: pVersion,
},
}
err = s.StateStore.SetConfig(storeConfigVal, dstB.ConfigSchema())
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to set state store configuration: %w", err))
return nil, diags
}
err = s.StateStore.Provider.SetConfig(providerConfigVal, dstB.ProviderSchema())
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to set state store provider configuration: %w", err))
return nil, diags
}
if err := sMgr.WriteState(s); err != nil {
diags = diags.Append(errBackendWriteSavedDiag(err))
return nil, diags
}
if err := sMgr.PersistState(); err != nil {
diags = diags.Append(errBackendWriteSavedDiag(err))
return nil, diags
}
return dstB, diags
}
// getStateStorageProviderVersion gets the current version of the state store provider that's in use. This is achieved
// by inspecting the current locks.
//
@ -2762,7 +3111,7 @@ func (m *Meta) backendInitFromConfig(c *configs.Backend) (backend.Backend, cty.V
//
// NOTE: the backend version of this method, `backendInitFromConfig`, prompts users for input if any required fields
// are missing from the backend config. In `stateStoreInitFromConfig` we don't do this, and instead users will see an error.
func (m *Meta) stateStoreInitFromConfig(c *configs.StateStore, locks *depsfile.Locks) (backend.Backend, cty.Value, cty.Value, tfdiags.Diagnostics) {
func (m *Meta) stateStoreInitFromConfig(c *configs.StateStore, locks *depsfile.Locks) (*backendPluggable.Pluggable, cty.Value, cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
factory, pDiags := m.StateStoreProviderFactoryFromConfig(c, locks)

View file

@ -6,6 +6,7 @@ package command
import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/tfdiags"
)
@ -160,15 +161,21 @@ configuration or state have been made.`, initReason)
)
}
type ssInitReason struct {
Reason string
Subject *hcl.Range
}
// errStateStoreInitDiag creates a diagnostic to present to users when
// users attempt to run a non-init command after making a change to their
// state_store configuration.
//
// An init reason should be provided as an argument.
func errStateStoreInitDiag(initReason string) tfdiags.Diagnostic {
msg := fmt.Sprintf(`Reason: %s
func errStateStoreInitDiag(ir *ssInitReason) tfdiags.Diagnostics {
var msg string
if ir != nil {
msg += fmt.Sprintf("Reason: %s\n\n", ir.Reason)
}
The "state store" is the interface that Terraform uses to store state when
msg += `The "state store" is the interface that Terraform uses to store state when
performing operations on the local machine. If this message is showing up,
it means that the Terraform configuration you're using is using a custom
configuration for state storage in Terraform.
@ -180,13 +187,27 @@ use the current configuration.
If the change reason above is incorrect, please verify your configuration
hasn't changed and try again. At this point, no changes to your existing
configuration or state have been made.`, initReason)
configuration or state have been made.`
return tfdiags.Sourceless(
var diags tfdiags.Diagnostics
if ir != nil && ir.Subject != nil {
diags = diags.Append(&hcl.Diagnostic{
Subject: ir.Subject,
Severity: hcl.DiagError,
Summary: "State store initialization required, please run \"terraform init\"",
Detail: msg,
})
return diags
}
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"State store initialization required, please run \"terraform init\"",
msg,
)
))
return diags
}
// errBackendInitCloudDiag creates a diagnostic to present to users when
@ -227,6 +248,23 @@ above, resolve it, and try again.`, innerError)
)
}
// errStateStoreWriteSavedDiag creates a diagnostic to present to users when
// an init command experiences an error while writing to the backend state file.
func errStateStoreWriteSavedDiag(innerError error) tfdiags.Diagnostic {
msg := fmt.Sprintf(`Error saving the state store configuration: %s
Terraform saves the complete state store configuration in a local file for
configuring the state store on future operations. This cannot be disabled. Errors
are usually due to simple file permission errors. Please look at the error
above, resolve it, and try again.`, innerError)
return tfdiags.Sourceless(
tfdiags.Error,
"State store initialization failed",
msg,
)
}
// errBackendNoExistingWorkspaces is returned by calling code when it expects a backend.Backend
// to report one or more workspaces exist.
//

View file

@ -2657,17 +2657,10 @@ func TestMetaBackend_stateStoreInitFromConfig(t *testing.T) {
m.testingOverrides = metaOverridesForProvider(mock)
// Code under test
b, _, _, diags := m.stateStoreInitFromConfig(config, locks)
_, _, _, diags := m.stateStoreInitFromConfig(config, locks)
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags.Err())
}
if _, ok := b.(*pluggable.Pluggable); !ok {
t.Fatalf(
"expected stateStoreInitFromConfig to return a backend.Backend interface with concrete type %s, but got something else: %#v",
"*pluggable.Pluggable",
b,
)
}
if !mock.SetStateStoreChunkSizeCalled {
t.Fatal("expected configuring the pluggable state store to include a call to SetStateStoreChunkSize on the provider")

View file

@ -315,6 +315,10 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe
HumanValue: stateMigrateLocalHuman,
JSONValue: stateMigrateLocalJSON,
},
"state_store_migrate_state_store": {
HumanValue: "Migrating from state store %q (%s) to %q (%s). Reason: %s.",
JSONValue: "Migrating from state store %q (%s) to %q (%s). Reason: %s.",
},
"empty_message": {
HumanValue: "",
JSONValue: "",
@ -379,6 +383,8 @@ const (
BackendMigrateStateStoreMessage InitMessageCode = "backend_migrate_state_store"
// StateMigrateLocalMessage indicates migration from state store to local
StateMigrateLocalMessage InitMessageCode = "state_store_migrate_local"
// StateStoreMigrationMessage indicates migration from state store to state store
StateStoreMigrationMessage InitMessageCode = "state_store_migrate_state_store"
// FindingMatchingVersionMessage indicates that Terraform is looking for a provider version that matches the constraint during installation
FindingMatchingVersionMessage InitMessageCode = "finding_matching_version_message"
// InstalledProviderVersionInfo describes a successfully installed provider along with its version

View file

@ -24,6 +24,7 @@ import (
// StateStore represents a "state_store" block inside a "terraform" block
// in a module or file.
type StateStore struct {
// Type is a state store type name
Type string
// Config is the full configuration of the state_store block, including the