Skip to content

Commit

Permalink
Mapping replacement
Browse files Browse the repository at this point in the history
  • Loading branch information
tobio committed Aug 6, 2024
1 parent 31f1aa7 commit a7f722a
Show file tree
Hide file tree
Showing 3 changed files with 381 additions and 0 deletions.
110 changes: 110 additions & 0 deletions internal/elasticsearch/index/index/mapping_modifier.go
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 internal/elasticsearch/index/index/mapping_modifier_test.go
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)
})
}
}
Loading

0 comments on commit a7f722a

Please sign in to comment.