providers/terraform: Explicit validate step

We were previously catching some errors at read time, but some type errors
were panicking because the cty.DynamicPseudoType arguments have no
automatic pre-type-checking done but this code was assuming they would
be objects.

Here we add an explicit validation step that includes both the backend
validation we were previously doing during read and some additional
type checking to ensure the two dynamic arguments are suitably-typed.
Having the separate validation step means that these problems can be
detected by "terraform validate", rather than only in "terraform plan"
or "terraform apply".
This commit is contained in:
Martin Atkins 2019-05-03 15:38:39 -07:00
parent d06edb1b62
commit 083af21d30
3 changed files with 212 additions and 67 deletions

View File

@ -42,79 +42,65 @@ func dataSourceRemoteStateGetSchema() providers.Schema {
} }
} }
func dataSourceRemoteStateRead(d *cty.Value) (cty.Value, tfdiags.Diagnostics) { func dataSourceRemoteStateValidate(cfg cty.Value) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
// Getting the backend implicitly validates the configuration for it,
// but we can only do that if it's all known already.
if cfg.GetAttr("config").IsWhollyKnown() && cfg.GetAttr("backend").IsKnown() {
_, moreDiags := getBackend(cfg)
diags = diags.Append(moreDiags)
} else {
// Otherwise we'll just type-check the config object itself.
configTy := cfg.GetAttr("config").Type()
if configTy != cty.DynamicPseudoType && !(configTy.IsObjectType() || configTy.IsMapType()) {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid backend configuration",
"The configuration must be an object value.",
cty.GetAttrPath("config"),
))
}
}
{
defaultsTy := cfg.GetAttr("defaults").Type()
if defaultsTy != cty.DynamicPseudoType && !(defaultsTy.IsObjectType() || defaultsTy.IsMapType()) {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid default values",
"Defaults must be given in an object value.",
cty.GetAttrPath("defaults"),
))
}
}
return diags
}
func dataSourceRemoteStateRead(d cty.Value) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
b, moreDiags := getBackend(d)
diags = diags.Append(moreDiags)
if diags.HasErrors() {
return cty.NilVal, diags
}
newState := make(map[string]cty.Value) newState := make(map[string]cty.Value)
newState["backend"] = d.GetAttr("backend") newState["backend"] = d.GetAttr("backend")
newState["config"] = d.GetAttr("config")
backendType := d.GetAttr("backend").AsString() workspaceName := backend.DefaultStateName
// Don't break people using the old _local syntax - but note warning above
if backendType == "_local" {
log.Println(`[INFO] Switching old (unsupported) backend "_local" to "local"`)
backendType = "local"
}
// Create the client to access our remote state
log.Printf("[DEBUG] Initializing remote state backend: %s", backendType)
f := backendInit.Backend(backendType)
if f == nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid backend configuration",
fmt.Sprintf("Unknown backend type: %s", backendType),
cty.Path(nil).GetAttr("backend"),
))
return cty.NilVal, diags
}
b := f()
config := d.GetAttr("config")
if config.IsNull() {
// We'll treat this as an empty configuration and see if the backend's
// schema and validation code will accept it.
config = cty.EmptyObjectVal
}
newState["config"] = config
schema := b.ConfigSchema()
// Try to coerce the provided value into the desired configuration type.
configVal, err := schema.CoerceValue(config)
if err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid backend configuration",
fmt.Sprintf("The given configuration is not valid for backend %q: %s.", backendType,
tfdiags.FormatError(err)),
cty.Path(nil).GetAttr("config"),
))
return cty.NilVal, diags
}
newVal, validateDiags := b.PrepareConfig(configVal)
diags = diags.Append(validateDiags)
if validateDiags.HasErrors() {
return cty.NilVal, diags
}
configVal = newVal
configureDiags := b.Configure(configVal)
if configureDiags.HasErrors() {
diags = diags.Append(configureDiags.Err())
return cty.NilVal, diags
}
name := backend.DefaultStateName
if workspaceVal := d.GetAttr("workspace"); !workspaceVal.IsNull() { if workspaceVal := d.GetAttr("workspace"); !workspaceVal.IsNull() {
newState["workspace"] = workspaceVal newState["workspace"] = workspaceVal
name = workspaceVal.AsString() workspaceName = workspaceVal.AsString()
} }
newState["workspace"] = cty.StringVal(name) newState["workspace"] = cty.StringVal(workspaceName)
state, err := b.StateMgr(name) state, err := b.StateMgr(workspaceName)
if err != nil { if err != nil {
diags = diags.Append(tfdiags.AttributeValue( diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error, tfdiags.Error,
@ -165,3 +151,69 @@ func dataSourceRemoteStateRead(d *cty.Value) (cty.Value, tfdiags.Diagnostics) {
return cty.ObjectVal(newState), diags return cty.ObjectVal(newState), diags
} }
func getBackend(cfg cty.Value) (backend.Backend, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
backendType := cfg.GetAttr("backend").AsString()
// Don't break people using the old _local syntax - but note warning above
if backendType == "_local" {
log.Println(`[INFO] Switching old (unsupported) backend "_local" to "local"`)
backendType = "local"
}
// Create the client to access our remote state
log.Printf("[DEBUG] Initializing remote state backend: %s", backendType)
f := backendInit.Backend(backendType)
if f == nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid backend configuration",
fmt.Sprintf("There is no backend type named %q.", backendType),
cty.Path(nil).GetAttr("backend"),
))
return nil, diags
}
b := f()
config := cfg.GetAttr("config")
if config.IsNull() {
// We'll treat this as an empty configuration and see if the backend's
// schema and validation code will accept it.
config = cty.EmptyObjectVal
}
if config.Type().IsMapType() { // The code below expects an object type, so we'll convert
config = cty.ObjectVal(config.AsValueMap())
}
schema := b.ConfigSchema()
// Try to coerce the provided value into the desired configuration type.
configVal, err := schema.CoerceValue(config)
if err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid backend configuration",
fmt.Sprintf("The given configuration is not valid for backend %q: %s.", backendType,
tfdiags.FormatError(err)),
cty.Path(nil).GetAttr("config"),
))
return nil, diags
}
newVal, validateDiags := b.PrepareConfig(configVal)
diags = diags.Append(validateDiags)
if validateDiags.HasErrors() {
return nil, diags
}
configVal = newVal
configureDiags := b.Configure(configVal)
if configureDiags.HasErrors() {
diags = diags.Append(configureDiags.Err())
return nil, diags
}
return b, diags
}

View File

@ -1,6 +1,7 @@
package terraform package terraform
import ( import (
"github.com/hashicorp/terraform/tfdiags"
"testing" "testing"
"github.com/apparentlymart/go-dump/dump" "github.com/apparentlymart/go-dump/dump"
@ -138,6 +139,80 @@ func TestState_basic(t *testing.T) {
}), }),
true, true,
}, },
"wrong type for config": {
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.StringVal("nope"),
}),
cty.NilVal,
true,
},
"wrong type for config with unknown backend": {
cty.ObjectVal(map[string]cty.Value{
"backend": cty.UnknownVal(cty.String),
"config": cty.StringVal("nope"),
}),
cty.NilVal,
true,
},
"wrong type for config with unknown config": {
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.UnknownVal(cty.String),
}),
cty.NilVal,
true,
},
"wrong type for defaults": {
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.ObjectVal(map[string]cty.Value{
"path": cty.StringVal("./test-fixtures/basic.tfstate"),
}),
"defaults": cty.StringVal("nope"),
}),
cty.NilVal,
true,
},
"config as map": {
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.MapVal(map[string]cty.Value{
"path": cty.StringVal("./test-fixtures/empty.tfstate"),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.MapVal(map[string]cty.Value{
"path": cty.StringVal("./test-fixtures/empty.tfstate"),
}),
"defaults": cty.NullVal(cty.DynamicPseudoType),
"outputs": cty.EmptyObjectVal,
"workspace": cty.StringVal(backend.DefaultStateName),
}),
false,
},
"defaults as map": {
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.ObjectVal(map[string]cty.Value{
"path": cty.StringVal("./test-fixtures/basic.tfstate"),
}),
"defaults": cty.MapValEmpty(cty.String),
}),
cty.ObjectVal(map[string]cty.Value{
"backend": cty.StringVal("local"),
"config": cty.ObjectVal(map[string]cty.Value{
"path": cty.StringVal("./test-fixtures/basic.tfstate"),
}),
"defaults": cty.MapValEmpty(cty.String),
"outputs": cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("bar"),
}),
"workspace": cty.StringVal(backend.DefaultStateName),
}),
false,
},
} }
for name, test := range tests { for name, test := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@ -146,7 +221,15 @@ func TestState_basic(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
got, diags := dataSourceRemoteStateRead(&config)
diags := dataSourceRemoteStateValidate(config)
var got cty.Value
if !diags.HasErrors() && config.IsWhollyKnown() {
var moreDiags tfdiags.Diagnostics
got, moreDiags = dataSourceRemoteStateRead(config)
diags = diags.Append(moreDiags)
}
if test.Err { if test.Err {
if !diags.HasErrors() { if !diags.HasErrors() {
@ -156,7 +239,7 @@ func TestState_basic(t *testing.T) {
t.Fatalf("unexpected errors: %s", diags.Err()) t.Fatalf("unexpected errors: %s", diags.Err())
} }
if !test.Want.RawEquals(got) { if test.Want != cty.NilVal && !test.Want.RawEquals(got) {
t.Errorf("wrong result\nconfig: %sgot: %swant: %s", dump.Value(config), dump.Value(got), dump.Value(test.Want)) t.Errorf("wrong result\nconfig: %sgot: %swant: %s", dump.Value(config), dump.Value(got), dump.Value(test.Want))
} }
}) })

View File

@ -40,11 +40,21 @@ func (p *Provider) PrepareProviderConfig(req providers.PrepareProviderConfigRequ
} }
// ValidateDataSourceConfig is used to validate the data source configuration values. // ValidateDataSourceConfig is used to validate the data source configuration values.
func (p *Provider) ValidateDataSourceConfig(providers.ValidateDataSourceConfigRequest) providers.ValidateDataSourceConfigResponse { func (p *Provider) ValidateDataSourceConfig(req providers.ValidateDataSourceConfigRequest) providers.ValidateDataSourceConfigResponse {
// FIXME: move the backend configuration validate call that's currently // FIXME: move the backend configuration validate call that's currently
// inside the read method into here so that we can catch provider configuration // inside the read method into here so that we can catch provider configuration
// errors in terraform validate as well as during terraform plan. // errors in terraform validate as well as during terraform plan.
var res providers.ValidateDataSourceConfigResponse var res providers.ValidateDataSourceConfigResponse
// This should not happen
if req.TypeName != "terraform_remote_state" {
res.Diagnostics.Append(fmt.Errorf("Error: unsupported data source %s", req.TypeName))
return res
}
diags := dataSourceRemoteStateValidate(req.Config)
res.Diagnostics = diags
return res return res
} }
@ -67,7 +77,7 @@ func (p *Provider) ReadDataSource(req providers.ReadDataSourceRequest) providers
return res return res
} }
newState, diags := dataSourceRemoteStateRead(&req.Config) newState, diags := dataSourceRemoteStateRead(req.Config)
res.State = newState res.State = newState
res.Diagnostics = diags res.Diagnostics = diags