diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 059ef2993771..36850d37033c 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -27,6 +27,7 @@ import ( "github.com/hashicorp/terraform/internal/backend/backendrun" backendInit "github.com/hashicorp/terraform/internal/backend/init" backendLocal "github.com/hashicorp/terraform/internal/backend/local" + backendPluggable "github.com/hashicorp/terraform/internal/backend/pluggable" "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" @@ -40,6 +41,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" + tfversion "github.com/hashicorp/terraform/version" ) // BackendOpts are the options used to initialize a backendrun.OperationsBackend. @@ -1526,6 +1528,147 @@ func (m *Meta) updateSavedBackendHash(cHash int, sMgr *clistate.LocalState) tfdi return diags } +// Initializing a saved state store from the backend state file (aka 'cache file', aka 'legacy state file') +func (m *Meta) savedStateStore(sMgr *clistate.LocalState, providerFactory providers.Factory) (backend.Backend, tfdiags.Diagnostics) { + // We're preparing a state_store version of backend.Backend. + // + // The provider and state store will be configured using the backend state file. + + var diags tfdiags.Diagnostics + var b backend.Backend + + s := sMgr.State() + + provider, err := providerFactory() + if err != nil { + diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) + return nil, diags + } + // We purposefully don't have a deferred call to the provider's Close method here because the calling code needs a + // running provider instance inside the returned backend.Backend instance. + // Stopping the provider process is the responsibility of the calling code. + + resp := provider.GetProviderSchema() + + if len(resp.StateStores) == 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider does not support pluggable state storage", + Detail: fmt.Sprintf("There are no state stores implemented by provider %s (%q)", + s.StateStore.Provider.Source.Type, + s.StateStore.Provider.Source), + }) + return nil, diags + } + + stateStoreSchema, exists := resp.StateStores[s.StateStore.Type] + if !exists { + suggestions := slices.Sorted(maps.Keys(resp.StateStores)) + suggestion := didyoumean.NameSuggestion(s.StateStore.Type, suggestions) + if suggestion != "" { + suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "State store not implemented by the provider", + Detail: fmt.Sprintf("State store %q is not implemented by provider %s (%q)%s", + s.StateStore.Type, + s.StateStore.Provider.Source.Type, + s.StateStore.Provider.Source, + suggestion), + }) + return nil, diags + } + + // Get the provider config from the backend state file. + providerConfigVal, err := s.StateStore.Provider.Config(resp.Provider.Body) + if err != nil { + diags = diags.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error reading provider configuration state", + Detail: fmt.Sprintf("Terraform experienced an error reading provider configuration for provider %s (%q) while configuring state store %s", + s.StateStore.Provider.Source.Type, + s.StateStore.Provider.Source, + s.StateStore.Type, + ), + }, + ) + return nil, diags + } + + // Get the state store config from the backend state file. + stateStoreConfigVal, err := s.StateStore.Config(stateStoreSchema.Body) + if err != nil { + diags = diags.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error reading state store configuration state", + Detail: fmt.Sprintf("Terraform experienced an error reading state store configuration for state store %s in provider %s (%q)", + s.StateStore.Type, + s.StateStore.Provider.Source.Type, + s.StateStore.Provider.Source, + ), + }, + ) + return nil, diags + } + + // Validate and configure the provider + // + // NOTE: there are no marks we need to remove at this point. + // We haven't added marks since the provider config from the backend state was used + // because the state-storage provider's config isn't going to be presented to the user via terminal output or diags. + validateResp := provider.ValidateProviderConfig(providers.ValidateProviderConfigRequest{ + Config: providerConfigVal, + }) + diags = diags.Append(validateResp.Diagnostics) + if diags.HasErrors() { + return nil, diags + } + + configureResp := provider.ConfigureProvider(providers.ConfigureProviderRequest{ + TerraformVersion: tfversion.SemVer.String(), + Config: providerConfigVal, + }) + diags = diags.Append(configureResp.Diagnostics) + if diags.HasErrors() { + return nil, diags + } + + // Validate and configure the state store + // + // NOTE: there are no marks we need to remove at this point. + // We haven't added marks since the state store config from the backend state was used + // because the state store's config isn't going to be presented to the user via terminal output or diags. + validateStoreResp := provider.ValidateStateStoreConfig(providers.ValidateStateStoreConfigRequest{ + TypeName: s.StateStore.Type, + Config: stateStoreConfigVal, + }) + diags = diags.Append(validateStoreResp.Diagnostics) + if diags.HasErrors() { + return nil, diags + } + + cfgStoreResp := provider.ConfigureStateStore(providers.ConfigureStateStoreRequest{ + TypeName: s.StateStore.Type, + Config: stateStoreConfigVal, + }) + diags = diags.Append(cfgStoreResp.Diagnostics) + if diags.HasErrors() { + return nil, diags + } + + // Now we have a fully configured state store, ready to be used. + // To make it usable we need to return it in a backend.Backend interface. + b, err = backendPluggable.NewPluggable(provider, s.StateStore.Type) + if err != nil { + diags = diags.Append(err) + } + + return b, diags +} + //------------------------------------------------------------------- // Reusable helper functions for backend management //------------------------------------------------------------------- diff --git a/internal/command/meta_backend_test.go b/internal/command/meta_backend_test.go index 9821bfe9903c..b745171cd711 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/cloud" + "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/workdir" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -35,7 +36,9 @@ import ( "github.com/zclconf/go-cty/cty" backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/backend/local" backendLocal "github.com/hashicorp/terraform/internal/backend/local" + "github.com/hashicorp/terraform/internal/backend/pluggable" backendInmem "github.com/hashicorp/terraform/internal/backend/remote-state/inmem" ) @@ -2401,6 +2404,97 @@ func TestMetaBackend_configureStateStoreVariableUse(t *testing.T) { } } +func TestSavedBackend(t *testing.T) { + // Create a temporary working directory + td := t.TempDir() + testCopyDir(t, testFixturePath("backend-unset"), td) // Backend state file describes local backend, config lacks backend config + t.Chdir(td) + + // Make a state manager for the backend state file, + // read state from file + m := testMetaBackend(t, nil) + statePath := filepath.Join(m.DataDir(), DefaultStateFilename) + sMgr := &clistate.LocalState{Path: statePath} + err := sMgr.RefreshState() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // Code under test + b, diags := m.savedBackend(sMgr) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + // The test fixtures used in this test include a backend state file describing + // a local backend with the non-default path value below (local-state.tfstate) + localB, ok := b.(*local.Local) + if !ok { + t.Fatalf("expected the returned backend to be a local backend, matching the test fixtures.") + } + if localB.StatePath != "local-state.tfstate" { + t.Fatalf("expected the local backend to be configured using the backend state file, but got unexpected configuration values.") + } +} + +func TestSavedStateStore(t *testing.T) { + t.Run("the returned state store is configured with the backend state and not the current config", 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 + t.Chdir(td) + + // Make a state manager for accessing the backend state file, + // and read the backend state from file + m := testMetaBackend(t, nil) + statePath := filepath.Join(m.DataDir(), DefaultStateFilename) + sMgr := &clistate.LocalState{Path: statePath} + err := sMgr.RefreshState() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // Prepare provider factories for use + mock := testStateStoreMock(t) + factory := func() (providers.Interface, error) { + return mock, nil + } + 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) + } + return providers.ConfigureProviderResponse{} + } + mock.ConfigureStateStoreFn = func(req providers.ConfigureStateStoreRequest) providers.ConfigureStateStoreResponse { + // Assert that the state store is configured using backend state file values from the fixtures + config := req.Config.AsValueMap() + if config["value"].AsString() != "old-value" { + t.Fatalf("expected the state store to be configured with values from the backend state file (the string \"old-value\"), not the config. Got: %#v", config) + } + return providers.ConfigureStateStoreResponse{} + } + + // Code under test + b, diags := m.savedStateStore(sMgr, factory) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + if _, ok := b.(*pluggable.Pluggable); !ok { + t.Fatalf( + "expected savedStateStore to return a backend.Backend interface with concrete type %s, but got something else: %#v", + "*pluggable.Pluggable", + b, + ) + } + }) + + // NOTE: the mock's functions include assertions about the values passed to + // the ConfigureProvider and ConfigureStateStore methods +} + func TestMetaBackend_GetStateStoreProviderFactory(t *testing.T) { // See internal/command/e2etest/meta_backend_test.go for test case // where a provider factory is found using a local provider cache diff --git a/internal/command/testdata/state-store-changed/.terraform/terraform.tfstate b/internal/command/testdata/state-store-changed/.terraform/terraform.tfstate index 2195bd34e430..2438ce6d2f8f 100644 --- a/internal/command/testdata/state-store-changed/.terraform/terraform.tfstate +++ b/internal/command/testdata/state-store-changed/.terraform/terraform.tfstate @@ -10,7 +10,9 @@ "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foo", - "config": {}, + "config": { + "region": "old-value" + }, "hash": 12345 }, "hash": 12345 diff --git a/internal/command/testdata/state-store-changed/main.tf b/internal/command/testdata/state-store-changed/main.tf index 085c2f36d3f1..3202130af995 100644 --- a/internal/command/testdata/state-store-changed/main.tf +++ b/internal/command/testdata/state-store-changed/main.tf @@ -5,7 +5,9 @@ terraform { } } state_store "test_store" { - provider "test" {} + provider "test" { + region = "changed-value" # changed versus backend state file + } value = "changed-value" # changed versus backend state file }