Commit 89cac3c2 authored by Derek Strickland's avatar Derek Strickland
Browse files

Add set resource type; begin refactoring to abstract methods

parent bbab9dfa
Branches unavailable
No related merge requests found
Showing with 378 additions and 144 deletions
+378 -144
......@@ -14,7 +14,7 @@ resource "ip" {
resource "github_token" {
set {
items = ["1234", "abcd"]
members = ["1234", "abcd"]
}
}
......
package resources
import (
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty/gocty"
)
// Range is a ResourceType that ensures resource configuration contains an integer
// value within the allowable upper and lower bounds.
type Range struct {
Upper int `hcl:"upper"`
Lower int `hcl:"lower"`
}
func (r *Range) Validate(iface interface{}) error {
val, ok := iface.(int)
if !ok {
return fmt.Errorf("invalid resource config: value %#v cannot be cast to int", iface)
}
if val < r.Lower {
return fmt.Errorf("invalid resource config: value %d cannot be less than lower bound %d", val, r.Lower)
}
if val > r.Upper {
return fmt.Errorf("invalid resource config: value %d cannot be greater than upper bound %d", val, r.Upper)
}
return nil
}
func (r *Range) validateConfig() error {
mErr := new(multierror.Error)
if r.Lower > r.Upper {
mErr = multierror.Append(mErr, fmt.Errorf("lower bound %d is greater than upper bound %d", r.Lower, r.Upper))
}
return mErr.ErrorOrNil()
}
func maybeParseRange(block *hcl.Block, result *Resource) hcl.Diagnostics {
var diags hcl.Diagnostics
rangeContent, _, rangeDiags := block.Body.PartialContent(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "range",
},
},
})
diags = append(diags, rangeDiags...)
if diags.HasErrors() {
return diags
}
for _, rangeBlock := range rangeContent.Blocks {
// TODO: This might need to be moved up to the Parse function level once we have another resource type.
if rangeBlock.Type != "range" {
return append(diags, &hcl.Diagnostic{
Severity: 0,
Summary: "range parse error",
Detail: fmt.Sprintf("invalid block type: %s", rangeBlock.Type),
Subject: &rangeBlock.TypeRange,
Context: &block.TypeRange,
})
}
// TODO: This logic might be best on the Resource type. Resource then can built itself.
result.Range = &Range{}
rangeBlockContent, _, rangeBlockDiags := rangeBlock.Body.PartialContent(&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "lower",
Required: true,
},
{
Name: "upper",
Required: true,
},
},
})
diags = append(diags, rangeBlockDiags...)
if diags.HasErrors() {
return diags
}
for _, attribute := range rangeBlockContent.Attributes {
val, valDiags := attribute.Expr.Value(nil)
diags = append(diags, valDiags...)
if diags.HasErrors() {
return diags
}
switch attribute.Name {
case "upper":
err := gocty.FromCtyValue(val, &result.Range.Upper)
if err != nil {
diags = appendDiag(diags, attribute, rangeBlock, "type conversion error", fmt.Sprintf("attribute %q could not be converted from cty value %#v", attribute.Name, val))
}
case "lower":
err := gocty.FromCtyValue(val, &result.Range.Lower)
if err != nil {
diags = appendDiag(diags, attribute, rangeBlock, "type conversion error", fmt.Sprintf("attribute %q could not be converted from cty value %#v", attribute.Name, val))
}
default:
diags = appendDiag(diags, attribute, rangeBlock, "unsupported attribute", fmt.Sprintf("attribute %q is not supported", attribute.Name))
}
}
}
return diags
}
......@@ -2,13 +2,9 @@ package resources
import (
"fmt"
"github.com/hashicorp/hcl/v2/hclsyntax"
"reflect"
"github.com/hashicorp/go-multierror"
hcl "github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/zclconf/go-cty/cty/gocty"
"github.com/hashicorp/hcl/v2/hclsyntax"
)
// Resource is a custom resource that users can configure to expose custom capabilities
......@@ -16,60 +12,29 @@ import (
type Resource struct {
Name string `hcl:"name,label"`
Range *Range `hcl:"range,block,optional"`
Set *Set `hcl:"set,block,optional"`
}
func (r *Resource) ValidateConfig() error {
mErr := new(multierror.Error)
// TODO: Should likely validate that only one type is set.
if r.Range != nil {
if err := r.Range.validateConfig(); err != nil {
mErr = multierror.Append(mErr, fmt.Errorf("invalid config: resource %s of type range returned error - %s", r.Name, err.Error()))
}
}
return mErr.ErrorOrNil()
}
// Range is a ResourceType that ensures resource configuration contains an integer
// value within the allowable upper and lower bounds.
type Range struct {
Upper int `hcl:"upper"`
Lower int `hcl:"lower"`
}
func (r *Range) Validate(iface interface{}) error {
val, ok := iface.(int)
if !ok {
return fmt.Errorf("invalid resource config: value %#v cannot be cast to int", iface)
}
if val < r.Lower {
return fmt.Errorf("invalid resource config: value %d cannot be less than lower bound %d", val, r.Lower)
}
if val > r.Upper {
return fmt.Errorf("invalid resource config: value %d cannot be greater than upper bound %d", val, r.Upper)
}
return nil
}
func (r *Range) validateConfig() error {
mErr := new(multierror.Error)
if r.Lower > r.Upper {
mErr = multierror.Append(mErr, fmt.Errorf("lower bound %d is greater than upper bound %d", r.Lower, r.Upper))
if r.Set != nil {
if err := r.Set.validateConfig(); err != nil {
mErr = multierror.Append(mErr, fmt.Errorf("invalid config: resource %s of type set returned error - %s", r.Name, err.Error()))
}
}
return mErr.ErrorOrNil()
}
var hclDecoder *gohcl.Decoder
func init() {
hclDecoder := &gohcl.Decoder{}
hclDecoder.RegisterBlockDecoder(reflect.TypeOf(Resource{}), decodeCustomResource)
}
// Parse parses the resource spec from the given string.
func Parse(tmplContent string, filename string) (*Resource, error) {
file, diags := hclsyntax.ParseConfig([]byte(tmplContent), filename, hcl.Pos{Byte: 0, Line: 1, Column: 1})
......@@ -104,11 +69,11 @@ func Parse(tmplContent string, filename string) (*Resource, error) {
result.Name = block.Labels[0]
diags = append(diags, parseRange(block, result)...)
diags = append(diags, maybeParseRange(block, result)...)
diags = append(diags, maybeParseSet(block, result)...)
// Add parsing logic for all custom resource block types here, then trap
// parsing errors once at the end of the loop.
// parsing errors once at the end of the loop. This should work because one
// block should only target one resource type. The rest should be noops.
if diags.HasErrors() {
return nil, diags
}
......@@ -117,98 +82,13 @@ func Parse(tmplContent string, filename string) (*Resource, error) {
return result, nil
}
func parseRange(block *hcl.Block, result *Resource) hcl.Diagnostics {
var diags hcl.Diagnostics
rangeContent, _, rangeDiags := block.Body.PartialContent(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "range",
},
},
func appendDiag(diags hcl.Diagnostics, attribute *hcl.Attribute, block *hcl.Block, summary, detail string) hcl.Diagnostics {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: summary,
Detail: detail,
Subject: &attribute.Range,
Context: &block.TypeRange,
})
diags = append(diags, rangeDiags...)
if diags.HasErrors() {
return diags
}
for _, rangeBlock := range rangeContent.Blocks {
if rangeBlock.Type != "range" {
return append(diags, &hcl.Diagnostic{
Severity: 0,
Summary: "range parse error",
Detail: fmt.Sprintf("invalid block type: %s", rangeBlock.Type),
Subject: &rangeBlock.TypeRange,
Context: &block.TypeRange,
})
}
result.Range = &Range{}
rangeBlockContent, _, rangeBlockDiags := rangeBlock.Body.PartialContent(&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "lower",
Required: true,
},
{
Name: "upper",
Required: true,
},
},
})
diags = append(diags, rangeBlockDiags...)
if diags.HasErrors() {
return diags
}
for _, attribute := range rangeBlockContent.Attributes {
val, valDiags := attribute.Expr.Value(nil)
diags = append(diags, valDiags...)
if diags.HasErrors() {
return diags
}
switch attribute.Name {
case "upper":
gocty.FromCtyValue(val, &result.Range.Upper)
case "lower":
gocty.FromCtyValue(val, &result.Range.Lower)
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "unsupported attribute",
Detail: fmt.Sprintf("attribute %q is not supported", attribute.Name),
Subject: &attribute.Range,
Context: &rangeBlock.TypeRange,
})
}
}
}
return diags
}
func decodeCustomResource(body hcl.Body, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
//t := val.(*Resource)
var diags hcl.Diagnostics
b, remain, moreDiags := body.PartialContent(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: "resource", LabelNames: []string{"name"}},
},
})
diags = append(diags, moreDiags...)
if len(b.Blocks) == 0 {
return nil
}
decoder := &gohcl.Decoder{}
diags = append(diags, decoder.DecodeBody(remain, ctx, val)...)
return diags
}
......@@ -17,10 +17,6 @@ resource "%s" {
}
`
type Resources struct {
Custom []*Resource `hcl:"resource,block"`
}
type testCase struct {
name string
value interface{}
......@@ -207,3 +203,124 @@ resource "%s" {
})
}
}
func TestResource_Set(t *testing.T) {
setTmpl := `
resource "%s" {
set {
members = [%s]
}
}
`
type testCase struct {
name string
value interface{}
members string
parseErr string
cfgErrMsg string
errMsg string
tmpl string
formatFn func(testCase) string
}
defaultFormatFn := func(tc testCase) string {
return fmt.Sprintf(tc.tmpl, tc.name, tc.members)
}
testCases := []testCase{
{
name: "invalid-config-no-members",
members: "",
value: "1234",
parseErr: "The argument \"members\" is required",
cfgErrMsg: "",
errMsg: "",
tmpl: `
resource "%s" {
set {
}
}
`,
formatFn: func(tc testCase) string {
return fmt.Sprintf(tc.tmpl, tc.name)
},
},
{
name: "invalid-config-empty-members",
members: "",
value: "1234",
parseErr: "",
cfgErrMsg: "has no members",
errMsg: "",
tmpl: setTmpl,
formatFn: defaultFormatFn,
},
{
name: "invalid-config-duplicate",
members: "\"1234\",\"1234\"",
value: "1234",
parseErr: "",
cfgErrMsg: "more than once",
errMsg: "",
tmpl: setTmpl,
formatFn: defaultFormatFn,
},
{
name: "valid",
members: "\"1234\",\"abcd\"",
value: "1234",
parseErr: "",
cfgErrMsg: "",
errMsg: "",
tmpl: setTmpl,
formatFn: defaultFormatFn,
},
{
name: "invalid-does-not-exist",
members: "\"1234\",\"abcd\"",
value: "4321",
parseErr: "",
cfgErrMsg: "",
errMsg: "not a member",
tmpl: setTmpl,
formatFn: defaultFormatFn,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resourceHCL := tc.formatFn(tc)
resource, diags := Parse(resourceHCL, tc.name)
if tc.parseErr != "" {
require.NotNil(t, diags)
require.Contains(t, diags.Error(), tc.parseErr)
return
}
require.NoError(t, diags)
require.NotNil(t, resource)
require.Equal(t, tc.name, resource.Name)
require.NotNil(t, resource.Set)
err := resource.ValidateConfig()
if tc.cfgErrMsg == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.cfgErrMsg)
return
}
err = resource.Set.Validate(tc.value)
if tc.errMsg == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.ErrorContains(t, err, tc.errMsg)
}
})
}
}
package resources
import (
"errors"
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty/gocty"
)
type Set struct {
Members []string `hcl:"members"`
}
func (s *Set) Validate(iface interface{}) error {
val, ok := iface.(string)
if !ok {
return fmt.Errorf("invalid resource config: value %#v cannot be cast to string", iface)
}
for _, member := range s.Members {
if member == val {
return nil
}
}
return fmt.Errorf("invalid resource config: value %s is not a member of set", val)
}
func (s *Set) validateConfig() error {
mErr := new(multierror.Error)
if len(s.Members) < 1 {
mErr = multierror.Append(mErr, errors.New("set has no members"))
}
entries := map[string]struct{}{}
for _, member := range s.Members {
if _, ok := entries[member]; ok {
mErr = multierror.Append(mErr, fmt.Errorf("member %s defined more than once", member))
}
entries[member] = struct{}{}
}
return mErr.ErrorOrNil()
}
func maybeParseSet(block *hcl.Block, result *Resource) hcl.Diagnostics {
var diags hcl.Diagnostics
content, _, contentDiags := block.Body.PartialContent(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "set",
},
},
})
diags = append(diags, contentDiags...)
if diags.HasErrors() {
return diags
}
for _, setBlock := range content.Blocks {
// TODO: This might need to be moved up to the Parse function level once we have another resource type.
if setBlock.Type != "set" {
return append(diags, &hcl.Diagnostic{
Severity: 0,
Summary: "set parse error",
Detail: fmt.Sprintf("invalid block type: %s", setBlock.Type),
Subject: &setBlock.TypeRange,
Context: &block.TypeRange,
})
}
// TODO: This logic might be best on the Resource type. Resource then can built itself.
result.Set = &Set{}
setBlockContent, _, setBlockDiags := setBlock.Body.PartialContent(&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "members",
Required: true,
},
},
})
diags = append(diags, setBlockDiags...)
if diags.HasErrors() {
return diags
}
for _, attribute := range setBlockContent.Attributes {
val, valDiags := attribute.Expr.Value(nil)
diags = append(diags, valDiags...)
if diags.HasErrors() {
return diags
}
switch attribute.Name {
case "members":
members := val.AsValueSlice()
for _, member := range members {
var m string
err := gocty.FromCtyValue(member, &m)
if err != nil {
diags = appendDiag(diags, attribute, setBlock, "type conversion error", fmt.Sprintf("attribute %q could not be converted from cty value %#v", attribute.Name, member))
} else {
result.Set.Members = append(result.Set.Members, m)
}
}
default:
diags = appendDiag(diags, attribute, setBlock, "unsupported attribute", fmt.Sprintf("attribute %q is not supported", attribute.Name))
}
}
}
return diags
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment