diff --git a/internal/command/testdata/invalid-state-store-configuration/missing-required-attr-in-provider/main.tf b/internal/command/testdata/invalid-state-store-configuration/missing-required-attr-in-provider/main.tf new file mode 100644 index 0000000000..ae0a1b6db1 --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/missing-required-attr-in-provider/main.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" { + # Test mock provider will create a required attribute for the provider + # and there are no attributes here in the config... + } + value = "foobar" + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/missing-required-attr-in-store/main.tf b/internal/command/testdata/invalid-state-store-configuration/missing-required-attr-in-store/main.tf new file mode 100644 index 0000000000..3296315d10 --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/missing-required-attr-in-store/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" { + # missing required attribute "value" + } + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/repeated-attr-in-provider/main.tf b/internal/command/testdata/invalid-state-store-configuration/repeated-attr-in-provider/main.tf new file mode 100644 index 0000000000..c423184060 --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/repeated-attr-in-provider/main.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" { + region = "region1" + region = "region2" # Should trigger an error + } + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/repeated-attr-in-store/main.tf b/internal/command/testdata/invalid-state-store-configuration/repeated-attr-in-store/main.tf new file mode 100644 index 0000000000..95dfe7798c --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/repeated-attr-in-store/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" {} + value = "value1" + value = "value2" # Should trigger an error + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/unknown-attr-in-provider/main.tf b/internal/command/testdata/invalid-state-store-configuration/unknown-attr-in-provider/main.tf new file mode 100644 index 0000000000..a241a32bfd --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/unknown-attr-in-provider/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" { + unknown = "this isn't in the test provider's schema" # Should trigger an error + } + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/unknown-attr-in-store/main.tf b/internal/command/testdata/invalid-state-store-configuration/unknown-attr-in-store/main.tf new file mode 100644 index 0000000000..6fdaf2b756 --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/unknown-attr-in-store/main.tf @@ -0,0 +1,11 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" {} + unknown = "this isn't in test_store's schema" # Should trigger an error + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/unknown-store-type/main.tf b/internal/command/testdata/invalid-state-store-configuration/unknown-store-type/main.tf new file mode 100644 index 0000000000..0366006bc7 --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/unknown-store-type/main.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_nonexistent" { # nonexistent is not a valid state store type in the mocked provider + provider "test" {} + } +} diff --git a/internal/command/testdata/invalid-state-store-configuration/valid-config/main.tf b/internal/command/testdata/invalid-state-store-configuration/valid-config/main.tf new file mode 100644 index 0000000000..b350a6d48a --- /dev/null +++ b/internal/command/testdata/invalid-state-store-configuration/valid-config/main.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } + state_store "test_store" { + provider "test" { + region = "saturn" + } + value = "foobar" + } +} + +# This config is valid, but the test will force the provider +# or state store's config validation methods to return an error. diff --git a/internal/command/validate.go b/internal/command/validate.go index c3bd859dbc..5e86d3db57 100644 --- a/internal/command/validate.go +++ b/internal/command/validate.go @@ -5,16 +5,21 @@ package command import ( "fmt" + "maps" "path/filepath" + "slices" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/terraform/internal/addrs" backendInit "github.com/hashicorp/terraform/internal/backend/init" + backendPluggable "github.com/hashicorp/terraform/internal/backend/pluggable" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/didyoumean" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -92,9 +97,11 @@ func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { // Validation of backend block, if present // Backend blocks live outside the Terraform graph so we have to do this separately. - backend := cfg.Module.Backend - if backend != nil { - diags = diags.Append(c.validateBackend(backend)) + switch { + case cfg.Module.Backend != nil: + diags = diags.Append(c.validateBackend(cfg.Module.Backend)) + case cfg.Module.StateStore != nil: + diags = diags.Append(c.validateStateStore(cfg.Module.StateStore)) } // Unless excluded, we'll also do a quick validation of the Terraform test files. These live @@ -134,7 +141,6 @@ func (c *ValidateCommand) validateTestFiles(cfg *configs.Config) tfdiags.Diagnos diags = diags.Append(file.Validate(cfg)) for _, run := range file.Runs { - if run.Module != nil { // Then we can also validate the referenced modules, but we are // only going to do this is if they are local modules. @@ -144,7 +150,6 @@ func (c *ValidateCommand) validateTestFiles(cfg *configs.Config) tfdiags.Diagnos // the registry, the expectation is that the author of the // module should have ran `terraform validate` themselves. if _, ok := run.Module.Source.(addrs.ModuleSourceLocal); ok { - if validated := validatedModules[run.Module.Source.String()]; !validated { // Since we can reference the same module twice, let's @@ -153,14 +158,12 @@ func (c *ValidateCommand) validateTestFiles(cfg *configs.Config) tfdiags.Diagnos validatedModules[run.Module.Source.String()] = true diags = diags.Append(c.validateConfig(run.ConfigUnderTest)) } - } diags = diags.Append(run.Validate(run.ConfigUnderTest)) } else { diags = diags.Append(run.Validate(cfg)) } - } } @@ -209,6 +212,114 @@ func (c *ValidateCommand) validateBackend(cfg *configs.Backend) tfdiags.Diagnost return diags } +// We validate the state store in an offline manner, so we use: +// - State store's PrepareConfig method to validate the state_store block. +// - Provider's ValidateProviderConfig to validate the nested provider block. +// We don't use the Configure method, as that will interact with third-party systems. +// +// The code in this method is very similar to the `stateStoreInitFromConfig` method, +// expect it doesn't configure the provider or the state store. +func (c *ValidateCommand) validateStateStore(cfg *configs.StateStore) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + locks, depsDiags := c.Meta.lockedDependencies() + if depsDiags.HasErrors() { + // Add some context to the error so it's obvious that it's related to the state store. + newDiag := &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unable to validate state store configuration", + Detail: fmt.Sprintf("An unexpected error was encountered when loading the dependency locks file. Make sure the working directory has been initialized and try again. Error: %s", diags.Err()), + Subject: &cfg.DeclRange, + } + return diags.Append(newDiag) + } + diags = diags.Append(depsDiags) // Preserve any warnings + + factory, pDiags := c.Meta.StateStoreProviderFactoryFromConfig(cfg, locks) + diags = diags.Append(pDiags) + if pDiags.HasErrors() { + return diags + } + + provider, err := factory() + if err != nil { + diags = diags.Append(fmt.Errorf("Unable to validate state store configuration. Terraform was unable to obtain a provider instance during state store initialization: %w", err)) + return diags + } + defer provider.Close() + + 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)", + cfg.Provider.Name, + cfg.ProviderAddr), + Subject: &cfg.DeclRange, + }) + return diags + } + + schema, exists := resp.StateStores[cfg.Type] + if !exists { + suggestions := slices.Sorted(maps.Keys(resp.StateStores)) + suggestion := didyoumean.NameSuggestion(cfg.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", + cfg.Type, cfg.Provider.Name, + cfg.ProviderAddr, suggestion), + Subject: &cfg.DeclRange, + }) + return diags + } + + // Handle the nested provider block. + pDecSpec := resp.Provider.Body.DecoderSpec() + pConfig := cfg.Provider.Config + providerConfigVal, pDecDiags := hcldec.Decode(pConfig, pDecSpec, nil) + diags = diags.Append(pDecDiags) + if pDecDiags.HasErrors() { + return diags + } + + // Handle the schema for the state store itself, excluding the provider block. + ssdecSpec := schema.Body.DecoderSpec() + stateStoreConfigVal, ssDecDiags := hcldec.Decode(cfg.Config, ssdecSpec, nil) + diags = diags.Append(ssDecDiags) + if ssDecDiags.HasErrors() { + return diags + } + + // Validate the provider config + // + // NOTE: We don't configure the provider because the validate command is offline-only. + validateResp := provider.ValidateProviderConfig(providers.ValidateProviderConfigRequest{ + Config: providerConfigVal, + }) + diags = diags.Append(validateResp.Diagnostics) + if validateResp.Diagnostics.HasErrors() { + return diags + } + + // Validate the state store config + // + // NOTE: We don't configure the state store because the validate command is offline-only. + p, err := backendPluggable.NewPluggable(provider, cfg.Type) + if err != nil { + diags = diags.Append(err) + } + _, validateDiags := p.PrepareConfig(stateStoreConfigVal) + diags = diags.Append(validateDiags) + return diags +} + func (c *ValidateCommand) Synopsis() string { return "Check whether the configuration is valid" } diff --git a/internal/command/validate_test.go b/internal/command/validate_test.go index 4ea1fe7f4b..447575e069 100644 --- a/internal/command/validate_test.go +++ b/internal/command/validate_test.go @@ -20,6 +20,7 @@ import ( "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/tfdiags" ) func setupTest(t *testing.T, fixturepath string, args ...string) (*terminal.TestOutput, int) { @@ -587,6 +588,309 @@ func TestValidate_backendBlocks(t *testing.T) { }) } +func TestValidate_stateStoreBlocks(t *testing.T) { + t.Run("invalid when state_store block contains a repeated attribute", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/repeated-attr-in-store" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("unexpected successful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Attribute redefined" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when state_store's provider block contains a repeated attribute", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/repeated-attr-in-provider" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("unexpected successful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Attribute redefined" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when the state store type is unknown in that provider", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/unknown-store-type" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("unexpected successful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: State store not implemented by the provider" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when the state store provider doesn't implement any stores", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/unknown-store-type" // ok to reuse; see mock provider for test setup + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + mock.GetProviderSchemaResponse.StateStores = map[string]providers.Schema{} // override to have no state stores + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("unexpected successful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Provider does not support pluggable state storage" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when there's an unknown attribute present in the states_store block", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/unknown-attr-in-store" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("expected an unsuccessful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Unsupported argument" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when there's an unknown attribute present in the states_store's provider block", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/unknown-attr-in-provider" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("expected an unsuccessful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Unsupported argument" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when a required attribute in state_store block is unset", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/missing-required-attr-in-store" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("expected an unsuccessful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Missing required argument" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when a required attribute in state_store's provider block is unset", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/missing-required-attr-in-provider" + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + // Make the provider's schema require an attribute that isn't set in the test fixture + mock.GetProviderSchemaResponse.Provider.Body = &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_attr": {Type: cty.String, Required: true}, + }, + } + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("expected an unsuccessful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error: Missing required argument" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when the state_store's provider's ValidateProviderConfig method returns an error", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/valid-config" // mock provider creates the errors + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + // Make the provider respond as if there's a problem with the provider config. + mock.ValidateProviderConfigResponse = &providers.ValidateProviderConfigResponse{ + Diagnostics: tfdiags.Diagnostics{}.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error from test", + "This test is forcing an error to be returned from the provider's ValidateProviderConfig method.", + )), + } + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("expected an unsuccessful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error from test" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) + + t.Run("invalid when the state_store's provider's ValidateStateStoreConfig method returns an error", func(t *testing.T) { + fixturePath := "invalid-state-store-configuration/valid-config" // mock provider creates the errors + view, done := testView(t) + mock := mockPluggableStateStorageProvider() + // Make the provider respond as if there's a problem with the state store config. + // The provider's ValidateStateStoreConfig method is called inside the PrepareConfig method of a Pluggable. + mock.ValidateStateStoreConfigResponse = &providers.ValidateStateStoreConfigResponse{ + Diagnostics: tfdiags.Diagnostics{}.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error from test", + "This test is forcing an error to be returned from the provider's ValidateStateStoreConfig method.", + )), + } + // Make mock happy. + // This check -that a provider is configured before state store config is validated- is specific to the mock + // and isn't performed by user-facing code in Terraform. The mock's behaviour is to highlight abnormal situations/ + // breaking of assumptions. + // Here we know validate doesn't configure the provider before using validation methods, so we can set this flag + // to bypass. + mock.ConfigureProviderCalled = true + c := &ValidateCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(mock), + View: view, + AllowExperimentalFeatures: true, + }, + } + args := []string{"-no-color", testFixturePath(fixturePath)} + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("expected an unsuccessful exit code %d\n\n%s", code, output.Stdout()) + } + expectedErr := "Error from test" + if !strings.Contains(output.Stderr(), expectedErr) { + t.Fatalf("unexpected error content: wanted %q, got: %s", + expectedErr, + output.Stderr(), + ) + } + }) +} + // Resources are validated using their schemas, so unknown or missing required attributes are identified. func TestValidate_resourceBlock(t *testing.T) { t.Run("invalid when block contains a repeated attribute", func(t *testing.T) {