feat: Enable validate command to validate state_store blocks. (#38157)

pull/38130/head^2
Sarah French 2 days ago committed by GitHub
parent 9d6cc85d05
commit c7da02b4a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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"
}
}

@ -0,0 +1,12 @@
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
state_store "test_store" {
provider "test" {
# missing required attribute "value"
}
}
}

@ -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
}
}
}

@ -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
}
}

@ -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
}
}
}

@ -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
}
}

@ -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" {}
}
}

@ -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.

@ -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"
}

@ -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) {

Loading…
Cancel
Save