-
Notifications
You must be signed in to change notification settings - Fork 92
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
381 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
package index | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"reflect" | ||
|
||
"github.com/elastic/terraform-provider-elasticstack/internal/utils" | ||
"github.com/hashicorp/terraform-plugin-framework/diag" | ||
"github.com/hashicorp/terraform-plugin-framework/path" | ||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" | ||
"github.com/hashicorp/terraform-plugin-framework/types/basetypes" | ||
) | ||
|
||
type mappingsPlanModifier struct{} | ||
|
||
func (p mappingsPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { | ||
if !utils.IsKnown(req.StateValue) { | ||
return | ||
} | ||
|
||
if !utils.IsKnown(req.ConfigValue) { | ||
return | ||
} | ||
|
||
stateStr := req.StateValue.ValueString() | ||
cfgStr := req.ConfigValue.ValueString() | ||
|
||
var stateMappings map[string]interface{} | ||
var cfgMappings map[string]interface{} | ||
|
||
// No error checking, schema validation ensures this is valid json | ||
_ = json.Unmarshal([]byte(stateStr), &stateMappings) | ||
_ = json.Unmarshal([]byte(cfgStr), &cfgMappings) | ||
|
||
if stateProps, ok := stateMappings["properties"]; ok { | ||
cfgProps, ok := cfgMappings["properties"] | ||
if !ok { | ||
resp.RequiresReplace = true | ||
return | ||
} | ||
|
||
requiresReplace, finalMappings, diags := p.modifyMappings(ctx, path.Root("mappings").AtMapKey("properties"), stateProps.(map[string]interface{}), cfgProps.(map[string]interface{})) | ||
resp.RequiresReplace = requiresReplace | ||
cfgMappings["properties"] = finalMappings | ||
resp.Diagnostics.Append(diags...) | ||
|
||
planBytes, err := json.Marshal(cfgMappings) | ||
if err != nil { | ||
resp.Diagnostics.AddAttributeError(req.Path, "Failed to marshal final mappings", err.Error()) | ||
return | ||
} | ||
|
||
resp.PlanValue = basetypes.NewStringValue(string(planBytes)) | ||
} | ||
} | ||
|
||
func (p mappingsPlanModifier) modifyMappings(ctx context.Context, initialPath path.Path, old map[string]interface{}, new map[string]interface{}) (bool, map[string]interface{}, diag.Diagnostics) { | ||
var diags diag.Diagnostics | ||
for k, v := range old { | ||
oldFieldSettings := v.(map[string]interface{}) | ||
newFieldSettings, ok := new[k] | ||
currentPath := initialPath.AtMapKey(k) | ||
// When field is removed, it'll be ignored in elasticsearch | ||
if !ok { | ||
diags.AddAttributeWarning(path.Root("mappings"), fmt.Sprintf("removing field [%s] in mappings is ignored.", currentPath), "Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely") | ||
new[k] = v | ||
continue | ||
} | ||
newSettings := newFieldSettings.(map[string]interface{}) | ||
// check if the "type" field exists and match with new one | ||
if s, ok := oldFieldSettings["type"]; ok { | ||
if ns, ok := newSettings["type"]; ok { | ||
if !reflect.DeepEqual(s, ns) { | ||
return true, new, diags | ||
} | ||
continue | ||
} else { | ||
return true, new, diags | ||
} | ||
} | ||
|
||
// if we have "mapping" field, let's call ourself to check again | ||
if s, ok := oldFieldSettings["properties"]; ok { | ||
currentPath = currentPath.AtMapKey("properties") | ||
if ns, ok := newSettings["properties"]; ok { | ||
requiresReplace, newProperties, d := p.modifyMappings(ctx, currentPath, s.(map[string]interface{}), ns.(map[string]interface{})) | ||
diags.Append(d...) | ||
newSettings["properties"] = newProperties | ||
if requiresReplace { | ||
return true, new, diags | ||
} | ||
} else { | ||
diags.AddAttributeWarning(path.Root("mappings"), fmt.Sprintf("removing field [%s] in mappings is ignored.", currentPath), "Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely") | ||
newSettings["properties"] = s | ||
} | ||
} | ||
} | ||
|
||
return false, new, diags | ||
} | ||
|
||
func (p mappingsPlanModifier) Description(_ context.Context) string { | ||
return "Preserves existing mappings which don't exist in config" | ||
} | ||
|
||
func (p mappingsPlanModifier) MarkdownDescription(ctx context.Context) string { | ||
return p.Description(ctx) | ||
} |
264 changes: 264 additions & 0 deletions
264
internal/elasticsearch/index/index/mapping_modifier_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,264 @@ | ||
package index | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"testing" | ||
|
||
"github.com/hashicorp/terraform-plugin-framework/diag" | ||
"github.com/hashicorp/terraform-plugin-framework/path" | ||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" | ||
"github.com/hashicorp/terraform-plugin-framework/types" | ||
"github.com/hashicorp/terraform-plugin-framework/types/basetypes" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func mapToJsonStringValue(t *testing.T, m map[string]interface{}) basetypes.StringValue { | ||
mBytes, err := json.Marshal(m) | ||
require.NoError(t, err) | ||
|
||
return types.StringValue(string(mBytes)) | ||
} | ||
|
||
func Test_PlanModifyString(t *testing.T) { | ||
t.Parallel() | ||
|
||
tests := []struct { | ||
name string | ||
stateMappings basetypes.StringValue | ||
configMappings basetypes.StringValue | ||
expectedPlanMappings basetypes.StringValue | ||
expectedDiags diag.Diagnostics | ||
expectedRequiresReplace bool | ||
}{ | ||
{ | ||
name: "should do nothing if the state value is unknown", | ||
stateMappings: basetypes.NewStringUnknown(), | ||
configMappings: basetypes.NewStringValue("{}"), | ||
}, | ||
{ | ||
name: "should do nothing if the state value is null", | ||
stateMappings: basetypes.NewStringNull(), | ||
configMappings: basetypes.NewStringValue("{}"), | ||
}, | ||
{ | ||
name: "should do nothing if the config value is unknown", | ||
configMappings: basetypes.NewStringUnknown(), | ||
stateMappings: basetypes.NewStringValue("{}"), | ||
}, | ||
{ | ||
name: "should do nothing if the config value is null", | ||
configMappings: basetypes.NewStringNull(), | ||
stateMappings: basetypes.NewStringValue("{}"), | ||
}, | ||
{ | ||
name: "should do nothing if the state mappings do not define any properties", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"not_properties": map[string]interface{}{ | ||
"hello": "world", | ||
}, | ||
}), | ||
configMappings: basetypes.NewStringValue("{}"), | ||
}, | ||
{ | ||
name: "requires replace if state mappings define properties but the config value does not", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"hello": "world", | ||
}, | ||
}), | ||
configMappings: basetypes.NewStringValue("{}"), | ||
expectedRequiresReplace: true, | ||
}, | ||
{ | ||
name: "should not alter the final plan when a new field is added", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
configMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
}, | ||
{ | ||
name: "requires replace when the type of an existing field is changed", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
configMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "int", | ||
}, | ||
}, | ||
}), | ||
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "int", | ||
}, | ||
}, | ||
}), | ||
expectedRequiresReplace: true, | ||
}, | ||
{ | ||
name: "should add the removed field to the plan and include a warning when a field is removed from config", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
configMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}), | ||
expectedDiags: diag.Diagnostics{ | ||
diag.NewAttributeWarningDiagnostic( | ||
path.Root("mappings"), | ||
`removing field [mappings["properties"]["field2"]] in mappings is ignored.`, | ||
"Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely", | ||
), | ||
}, | ||
}, | ||
{ | ||
name: "should add the removed field to the plan and include a warning when a sub-field is removed from config", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}, | ||
}, | ||
}), | ||
configMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field3": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}, | ||
}, | ||
}), | ||
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
"field3": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}, | ||
}, | ||
}), | ||
expectedDiags: diag.Diagnostics{ | ||
diag.NewAttributeWarningDiagnostic( | ||
path.Root("mappings"), | ||
`removing field [mappings["properties"]["field1"]["properties"]["field2"]] in mappings is ignored.`, | ||
"Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely", | ||
), | ||
}, | ||
}, | ||
{ | ||
name: "requires replace when a sub-fields type is changed", | ||
stateMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field2": map[string]interface{}{ | ||
"type": "string", | ||
}, | ||
}, | ||
}, | ||
}, | ||
}), | ||
configMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field2": map[string]interface{}{ | ||
"type": "int", | ||
}, | ||
}, | ||
}, | ||
}, | ||
}), | ||
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field1": map[string]interface{}{ | ||
"properties": map[string]interface{}{ | ||
"field2": map[string]interface{}{ | ||
"type": "int", | ||
}, | ||
}, | ||
}, | ||
}, | ||
}), | ||
expectedRequiresReplace: true, | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
modifier := mappingsPlanModifier{} | ||
resp := planmodifier.StringResponse{} | ||
modifier.PlanModifyString(context.Background(), planmodifier.StringRequest{ | ||
ConfigValue: tt.configMappings, | ||
StateValue: tt.stateMappings, | ||
}, &resp) | ||
|
||
require.Equal(t, tt.expectedDiags, resp.Diagnostics) | ||
require.Equal(t, tt.expectedPlanMappings, resp.PlanValue) | ||
require.Equal(t, tt.expectedRequiresReplace, resp.RequiresReplace) | ||
}) | ||
} | ||
} |
Oops, something went wrong.