diff --git a/config/configschema/internal_validate.go b/config/configschema/internal_validate.go new file mode 100644 index 0000000000..33cbe884f1 --- /dev/null +++ b/config/configschema/internal_validate.go @@ -0,0 +1,92 @@ +package configschema + +import ( + "fmt" + "regexp" + + "github.com/zclconf/go-cty/cty" + + multierror "github.com/hashicorp/go-multierror" +) + +var validName = regexp.MustCompile(`^[a-z0-9_]+$`) + +// InternalValidate returns an error if the receiving block and its child +// schema definitions have any consistencies with the documented rules for +// valid schema. +// +// This is intended to be used within unit tests to detect when a given +// schema is invalid. +func (b *Block) InternalValidate() error { + if b == nil { + return fmt.Errorf("top-level block schema is nil") + } + return b.internalValidate("", nil) + +} + +func (b *Block) internalValidate(prefix string, err error) error { + for name, attrS := range b.Attributes { + if attrS == nil { + err = multierror.Append(err, fmt.Errorf("%s%s: attribute schema is nil", prefix, name)) + continue + } + if !validName.MatchString(name) { + err = multierror.Append(err, fmt.Errorf("%s%s: name may contain only lowercase letters, digits and underscores", prefix, name)) + } + if attrS.Optional == false && attrS.Required == false && attrS.Computed == false { + err = multierror.Append(err, fmt.Errorf("%s%s: must set Optional, Required or Computed", prefix, name)) + } + if attrS.Optional && attrS.Required { + err = multierror.Append(err, fmt.Errorf("%s%s: cannot set both Optional and Required", prefix, name)) + } + if attrS.Computed && attrS.Required { + err = multierror.Append(err, fmt.Errorf("%s%s: cannot set both Computed and Required", prefix, name)) + } + if attrS.Type == cty.NilType { + err = multierror.Append(err, fmt.Errorf("%s%s: Type must be set to something other than cty.NilType", prefix, name)) + } + } + + for name, blockS := range b.BlockTypes { + if blockS == nil { + err = multierror.Append(err, fmt.Errorf("%s%s: block schema is nil", prefix, name)) + continue + } + + if _, isAttr := b.Attributes[name]; isAttr { + err = multierror.Append(err, fmt.Errorf("%s%s: name defined as both attribute and child block type", prefix, name)) + } else if !validName.MatchString(name) { + err = multierror.Append(err, fmt.Errorf("%s%s: name may contain only lowercase letters, digits and underscores", prefix, name)) + } + + if blockS.MinItems < 0 || blockS.MaxItems < 0 { + err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must both be greater than zero", prefix, name)) + } + + switch blockS.Nesting { + case NestingSingle: + switch { + case blockS.MinItems != blockS.MaxItems: + err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must match in NestingSingle mode", prefix, name)) + case blockS.MinItems < 0 || blockS.MinItems > 1: + err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must be set to either 0 or 1 in NestingSingle mode", prefix, name)) + } + case NestingList, NestingSet: + if blockS.MinItems > blockS.MaxItems && blockS.MaxItems != 0 { + err = multierror.Append(err, fmt.Errorf("%s%s: MinItems must be less than or equal to MaxItems in %s mode", prefix, name, blockS.Nesting)) + } + case NestingMap: + if blockS.MinItems != 0 || blockS.MaxItems != 0 { + err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must both be 0 in NestingMap mode", prefix, name)) + } + default: + err = multierror.Append(err, fmt.Errorf("%s%s: invalid nesting mode %s", prefix, name, blockS.Nesting)) + } + + subPrefix := prefix + name + "." + err = blockS.Block.internalValidate(subPrefix, err) + } + + return err +} diff --git a/config/configschema/internal_validate_test.go b/config/configschema/internal_validate_test.go new file mode 100644 index 0000000000..1e2b1d37b3 --- /dev/null +++ b/config/configschema/internal_validate_test.go @@ -0,0 +1,238 @@ +package configschema + +import ( + "testing" + + "github.com/zclconf/go-cty/cty" + + multierror "github.com/hashicorp/go-multierror" +) + +func TestBlockInternalValidate(t *testing.T) { + tests := map[string]struct { + Block *Block + ErrCount int + }{ + "empty": { + &Block{}, + 0, + }, + "valid": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Type: cty.String, + Required: true, + }, + "bar": &Attribute{ + Type: cty.String, + Optional: true, + }, + "baz": &Attribute{ + Type: cty.String, + Computed: true, + }, + "baz_maybe": &Attribute{ + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + BlockTypes: map[string]*NestedBlock{ + "single": &NestedBlock{ + Nesting: NestingSingle, + Block: Block{}, + }, + "single_required": &NestedBlock{ + Nesting: NestingSingle, + Block: Block{}, + MinItems: 1, + MaxItems: 1, + }, + "list": &NestedBlock{ + Nesting: NestingList, + Block: Block{}, + }, + "list_required": &NestedBlock{ + Nesting: NestingList, + Block: Block{}, + MinItems: 1, + }, + "set": &NestedBlock{ + Nesting: NestingSet, + Block: Block{}, + }, + "set_required": &NestedBlock{ + Nesting: NestingSet, + Block: Block{}, + MinItems: 1, + }, + "map": &NestedBlock{ + Nesting: NestingMap, + Block: Block{}, + }, + }, + }, + 0, + }, + "attribute with no flags set": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Type: cty.String, + }, + }, + }, + 1, // must set one of the flags + }, + "attribute required and optional": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Type: cty.String, + Required: true, + Optional: true, + }, + }, + }, + 1, // both required and optional + }, + "attribute required and computed": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Type: cty.String, + Required: true, + Computed: true, + }, + }, + }, + 1, // both required and computed + }, + "attribute optional and computed": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + 0, + }, + "attribute with missing type": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Optional: true, + }, + }, + }, + 1, // Type must be set + }, + "attribute with invalid name": { + &Block{ + Attributes: map[string]*Attribute{ + "fooBar": &Attribute{ + Type: cty.String, + Optional: true, + }, + }, + }, + 1, // name may not contain uppercase letters + }, + "block type with invalid name": { + &Block{ + BlockTypes: map[string]*NestedBlock{ + "fooBar": &NestedBlock{ + Nesting: NestingSingle, + }, + }, + }, + 1, // name may not contain uppercase letters + }, + "colliding names": { + &Block{ + Attributes: map[string]*Attribute{ + "foo": &Attribute{ + Type: cty.String, + Optional: true, + }, + }, + BlockTypes: map[string]*NestedBlock{ + "foo": &NestedBlock{ + Nesting: NestingSingle, + }, + }, + }, + 1, // "foo" is defined as both attribute and block type + }, + "nested block with badness": { + &Block{ + BlockTypes: map[string]*NestedBlock{ + "bad": &NestedBlock{ + Nesting: NestingSingle, + Block: Block{ + Attributes: map[string]*Attribute{ + "nested_bad": &Attribute{ + Type: cty.String, + Required: true, + Optional: true, + }, + }, + }, + }, + }, + }, + 1, // nested_bad is both required and optional + }, + "nil": { + nil, + 1, // block is nil + }, + "nil attr": { + &Block{ + Attributes: map[string]*Attribute{ + "bad": nil, + }, + }, + 1, // attribute schema is nil + }, + "nil block type": { + &Block{ + BlockTypes: map[string]*NestedBlock{ + "bad": nil, + }, + }, + 1, // block schema is nil + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + errs := multierrorErrors(test.Block.InternalValidate()) + if got, want := len(errs), test.ErrCount; got != want { + t.Errorf("wrong number of errors %d; want %d", got, want) + for _, err := range errs { + t.Logf("- %s", err.Error()) + } + } + }) + } +} + +func multierrorErrors(err error) []error { + // A function like this should really be part of the multierror package... + + if err == nil { + return nil + } + + switch terr := err.(type) { + case *multierror.Error: + return terr.Errors + default: + return []error{err} + } +}