diff --git a/apstra/apstra_validator/attribute_conflict.go b/apstra/apstra_validator/attribute_conflict.go new file mode 100644 index 00000000..84b143bf --- /dev/null +++ b/apstra/apstra_validator/attribute_conflict.go @@ -0,0 +1,237 @@ +package apstravalidator + +import ( + "context" + "encoding/base64" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "strings" +) + +type CollectionValidator interface { + validator.List + validator.Map + validator.Set +} + +var _ CollectionValidator = attributeConflictValidator{} + +// attributeConflictValidator ensures that no two elements of a list, map, or +// set of objects use the same value across all attributes enumerated in +// keyAttrs. +// +// For example, if keyAttrs contains just {"name"}, then having two objects +// with `name: "foo"` will produce a validation error. +// +// If keyAttrs contains {"protocol", "port"} then having two objects with +// `protocol: "TCP"` and `port: 80` will produce a validation error. +// +// If keyAttrs is empty, then values across all attributes are evaluated. +type attributeConflictValidator struct { + keyAttrs []string + caseInsensitive bool +} + +func (o attributeConflictValidator) Description(_ context.Context) string { + if len(o.keyAttrs) == 0 { + return "Ensure that no two collection (list/map/set) members share values for all attributes" + } + + return fmt.Sprintf( + "Ensure that no two collection (list/map/set) members share values for these attributes: [%s]", + strings.Join(o.keyAttrs, " "), + ) +} + +func (o attributeConflictValidator) MarkdownDescription(ctx context.Context) string { + return o.Description(ctx) +} + +func (o attributeConflictValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + foundKeyValueCombinations := make(map[string]bool) + for i, element := range req.ConfigValue.Elements() { // loop over set members + validateRequest := attributeConflictValidateElementRequest{ + elementValue: element, + elementPath: req.Path.AtListIndex(i), + foundKeyValueCombinations: foundKeyValueCombinations, + path: req.Path, + } + validateResponse := attributeConflictValidateElementResponse{} + o.validateElement(ctx, validateRequest, &validateResponse) + resp.Diagnostics.Append(validateResponse.Diagnostics...) + } +} + +func (o attributeConflictValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + foundKeyValueCombinations := make(map[string]bool) + for mapKey, element := range req.ConfigValue.Elements() { // loop over set members + validateRequest := attributeConflictValidateElementRequest{ + elementValue: element, + elementPath: req.Path.AtMapKey(mapKey), + foundKeyValueCombinations: foundKeyValueCombinations, + path: req.Path, + } + validateResponse := attributeConflictValidateElementResponse{} + o.validateElement(ctx, validateRequest, &validateResponse) + resp.Diagnostics.Append(validateResponse.Diagnostics...) + } +} + +func (o attributeConflictValidator) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + foundKeyValueCombinations := make(map[string]bool) + for _, element := range req.ConfigValue.Elements() { // loop over set members + validateRequest := attributeConflictValidateElementRequest{ + elementValue: element, + elementPath: req.Path.AtSetValue(element), + foundKeyValueCombinations: foundKeyValueCombinations, + path: req.Path, + } + validateResponse := attributeConflictValidateElementResponse{} + o.validateElement(ctx, validateRequest, &validateResponse) + resp.Diagnostics.Append(validateResponse.Diagnostics...) + } +} + +type attributeConflictValidateElementRequest struct { + elementValue attr.Value + elementPath path.Path + foundKeyValueCombinations map[string]bool + path path.Path +} + +type attributeConflictValidateElementResponse struct { + Diagnostics diag.Diagnostics +} + +func (o *attributeConflictValidator) validateElement(ctx context.Context, req attributeConflictValidateElementRequest, resp *attributeConflictValidateElementResponse) { + objectValuable, ok := req.elementValue.(basetypes.ObjectValuable) + if !ok { + resp.Diagnostics.AddAttributeError( + req.path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares a Object values validator, however its values do not implement the types.ObjectValuable interface for custom Object types. "+ + "This is likely an issue with terraform-plugin-framework and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.path.String())+ + fmt.Sprintf("Element Type: %T\n", req.elementValue.Type(ctx))+ + fmt.Sprintf("Element Value Type: %T\n", req.elementValue), + ) + + return + } + + objectValue, d := objectValuable.ToObjectValue(ctx) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + // if the caller didn't specify any "key" attributes we use all of them + if len(o.keyAttrs) == 0 { + for k := range objectValue.Attributes() { + o.keyAttrs = append(o.keyAttrs, k) + } + } + + // map of key attribute names used to quickly recognize whether an attribute is interesting + keyAttributeNames := make(map[string]bool, len(o.keyAttrs)) + for _, key := range o.keyAttrs { + keyAttributeNames[key] = true + } + + keyValuesMap := make(map[string]string, len(keyAttributeNames)) + for attrName, attrValue := range objectValue.Attributes() { // loop over set member attributes + if !keyAttributeNames[attrName] { + continue // attribute is not interesting + } + + if attrValue.IsUnknown() { + return // cannot validate when attribute is unknown + } + + var valueToCompare string // a configured value we're checking for unique-ness + if o.caseInsensitive { + valueToCompare = strings.ToLower(attrValue.String()) + } else { + valueToCompare = attrValue.String() + } + + keyValuesMap[attrName] = base64.StdEncoding.EncodeToString([]byte(valueToCompare)) + if len(keyValuesMap) == len(keyAttributeNames) { + break // keyValuesMap is full, no need to look at remaining attributes + } + } + + // did we find all of the required "key attributes" ? + if len(keyValuesMap) < len(keyAttributeNames) { + // collect object's attribute names so we can complain about them + var attrNames []string + for attrName := range objectValue.Attributes() { + attrNames = append(attrNames, attrName) + } + + resp.Diagnostics.AddAttributeError( + req.path, + "Invalid Validator for Element Value", + "While performing schema-based validation, an unexpected error occurred. "+ + "The attribute declares an Object values validator which has been asked "+ + "to validate attributes not present in the object. "+ + "This issue should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Path: %s\n", req.path.String())+ + fmt.Sprintf("Element Attributes: '%s'\n", strings.Join(attrNames, "', '"))+ + fmt.Sprintf("Element Attributes to validate: '%s'\n", strings.Join(o.keyAttrs, "', '")), + ) + + return + } + + sb := strings.Builder{} + for i := range o.keyAttrs { + if i == 0 { + sb.WriteString(keyValuesMap[o.keyAttrs[i]]) + } else { + sb.WriteString(":" + keyValuesMap[o.keyAttrs[i]]) + } + } + + if req.foundKeyValueCombinations[sb.String()] { // seen this value before? + resp.Diagnostics.AddAttributeError( + req.elementPath, + fmt.Sprintf("%s collision", o.keyAttrs), + fmt.Sprintf("Two objects cannot use the same value "+ + "combination for these attributes: ['%s'] (case sensitive: %t)", + strings.Join(o.keyAttrs, "', '"), o.caseInsensitive), + ) + } else { + req.foundKeyValueCombinations[sb.String()] = true // log the name for future collision checks + } +} + +func UniqueValueCombinationsAt(attrNames ...string) CollectionValidator { + return attributeConflictValidator{ + keyAttrs: attrNames, + } +} + +func UniqueInsensitiveValueCombinationsAt(attrNames ...string) CollectionValidator { + return attributeConflictValidator{ + keyAttrs: attrNames, + caseInsensitive: true, + } +} diff --git a/apstra/apstra_validator/attribute_conflict_test.go b/apstra/apstra_validator/attribute_conflict_test.go new file mode 100644 index 00000000..71dba9fd --- /dev/null +++ b/apstra/apstra_validator/attribute_conflict_test.go @@ -0,0 +1,576 @@ +package apstravalidator + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "log" + "testing" +) + +func TestAttributeConflictValidator(t *testing.T) { + ctx := context.Background() + + type testCase struct { + keyAttrNames []string + attrTypes map[string]attr.Type + attrValues map[string]attr.Value + expectError bool + caseInsensitive bool + } + + attrValueSlice := func(in map[string]attr.Value) []attr.Value { + result := make([]attr.Value, len(in)) + var i int + for _, attrValue := range in { + result[i] = attrValue + i++ + } + return result + } + + testCases := map[string]testCase{ + "one_key_no_collision": { + keyAttrNames: []string{"key1"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("bar"), + }, + ), + }, + expectError: false, + }, + "one_key_collision": { + keyAttrNames: []string{"key1"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + }, + ), + }, + expectError: true, + }, + "one_key_plus_extras_no_collision": { + keyAttrNames: []string{"key1"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "extra1": types.StringValue("foo"), + "extra2": types.StringValue("foo"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("bar"), + "extra1": types.StringValue("bar"), + "extra2": types.StringValue("bar"), + }, + ), + }, + expectError: false, + }, + "one_key_plus_extras_collision": { + keyAttrNames: []string{"key1"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "extra1": types.StringValue("bar"), + "extra2": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "extra1": types.StringValue("bar"), + "extra2": types.StringValue("baz"), + }, + ), + }, + expectError: true, + }, + "three_keys_no_collision": { + keyAttrNames: []string{"key1", "key2", "key3"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("bar"), + "key2": types.StringValue("baz"), + "key3": types.StringValue("foo"), + }, + ), + }, + expectError: false, + }, + "three_keys_with_extras_no_collision": { + keyAttrNames: []string{"key1", "key2", "key3"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + "extra3": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + "extra3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + "extra1": types.StringValue("foo"), + "extra2": types.StringValue("bar"), + "extra3": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + "extra3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("bar"), + "key2": types.StringValue("baz"), + "key3": types.StringValue("foo"), + "extra1": types.StringValue("foo"), + "extra2": types.StringValue("bar"), + "extra3": types.StringValue("baz"), + }, + ), + }, + expectError: false, + }, + "three_keys_collision": { + keyAttrNames: []string{"key1", "key2", "key3"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + }, + ), + }, + expectError: true, + }, + "three_keys_with_extras_collision": { + keyAttrNames: []string{"key1", "key2", "key3"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + "extra3": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + "extra3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + "extra1": types.StringValue("foo"), + "extra2": types.StringValue("bar"), + "extra3": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + "extra1": types.StringType, + "extra2": types.StringType, + "extra3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + "extra1": types.StringValue("foo"), + "extra2": types.StringValue("bar"), + "extra3": types.StringValue("baz"), + }, + ), + }, + expectError: true, + }, + "all_keys_no_collision": { + keyAttrNames: []string{}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("bar"), + "key2": types.StringValue("baz"), + "key3": types.StringValue("foo"), + }, + ), + }, + expectError: false, + }, + "all_keys_collision": { + keyAttrNames: []string{}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + "key2": types.StringType, + "key3": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + "key2": types.StringValue("bar"), + "key3": types.StringValue("baz"), + }, + ), + }, + expectError: true, + }, + "one_key_no_collision_case_sensitive": { + keyAttrNames: []string{"key1"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("FOO"), + }, + ), + }, + expectError: false, + caseInsensitive: false, + }, + "one_key_collision_case_insensitive": { + keyAttrNames: []string{"key1"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + }, + ), + "two": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("FOO"), + }, + ), + }, + expectError: true, + caseInsensitive: true, + }, + "missing_key": { + keyAttrNames: []string{"key1", "key2"}, + attrTypes: map[string]attr.Type{ + "key1": types.StringType, + }, + attrValues: map[string]attr.Value{ + "one": types.ObjectValueMust( + map[string]attr.Type{ + "key1": types.StringType, + }, + map[string]attr.Value{ + "key1": types.StringValue("foo"), + }, + ), + }, + expectError: true, + }, + } + + // test list validation + for tName, tCase := range testCases { + tName, tCase := tName, tCase + t.Run(tName, func(t *testing.T) { + t.Parallel() + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: types.ListValueMust(types.ObjectType{AttrTypes: tCase.attrTypes}, attrValueSlice(tCase.attrValues)), + } + response := validator.ListResponse{} + var v CollectionValidator + if tCase.caseInsensitive { + v = UniqueInsensitiveValueCombinationsAt(tCase.keyAttrNames...) + } else { + v = UniqueValueCombinationsAt(tCase.keyAttrNames...) + } + v.ValidateList(ctx, request, &response) + + if !response.Diagnostics.HasError() && tCase.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !tCase.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + + if response.Diagnostics.HasError() { + for _, diags := range response.Diagnostics.Errors() { + log.Println(v.Description(ctx)) + log.Println(diags.Summary()) + log.Println(diags.Detail()) + } + } + }) + } + + // test map validation + for tName, tCase := range testCases { + tName, tCase := tName, tCase + t.Run(tName, func(t *testing.T) { + t.Parallel() + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: types.MapValueMust(types.ObjectType{AttrTypes: tCase.attrTypes}, tCase.attrValues), + } + response := validator.MapResponse{} + var v CollectionValidator + if tCase.caseInsensitive { + v = UniqueInsensitiveValueCombinationsAt(tCase.keyAttrNames...) + } else { + v = UniqueValueCombinationsAt(tCase.keyAttrNames...) + } + v.ValidateMap(ctx, request, &response) + + if !response.Diagnostics.HasError() && tCase.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !tCase.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + + if response.Diagnostics.HasError() { + for _, diags := range response.Diagnostics.Errors() { + log.Println(v.Description(ctx)) + log.Println(diags.Summary()) + log.Println(diags.Detail()) + } + } + }) + } + + // test set validation + for tName, tCase := range testCases { + tName, tCase := tName, tCase + t.Run(tName, func(t *testing.T) { + t.Parallel() + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: types.SetValueMust(types.ObjectType{AttrTypes: tCase.attrTypes}, attrValueSlice(tCase.attrValues)), + } + response := validator.SetResponse{} + var v CollectionValidator + if tCase.caseInsensitive { + v = UniqueInsensitiveValueCombinationsAt(tCase.keyAttrNames...) + } else { + v = UniqueValueCombinationsAt(tCase.keyAttrNames...) + } + v.ValidateSet(ctx, request, &response) + + if !response.Diagnostics.HasError() && tCase.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !tCase.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + + if response.Diagnostics.HasError() { + for _, diags := range response.Diagnostics.Errors() { + log.Println(v.Description(ctx)) + log.Println(diags.Summary()) + log.Println(diags.Detail()) + } + } + }) + } +}