Skip to content

Commit a7f722a

Browse files
committed
Mapping replacement
1 parent 31f1aa7 commit a7f722a

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-0
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package index
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"reflect"
8+
9+
"github.com/elastic/terraform-provider-elasticstack/internal/utils"
10+
"github.com/hashicorp/terraform-plugin-framework/diag"
11+
"github.com/hashicorp/terraform-plugin-framework/path"
12+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
13+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
14+
)
15+
16+
type mappingsPlanModifier struct{}
17+
18+
func (p mappingsPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
19+
if !utils.IsKnown(req.StateValue) {
20+
return
21+
}
22+
23+
if !utils.IsKnown(req.ConfigValue) {
24+
return
25+
}
26+
27+
stateStr := req.StateValue.ValueString()
28+
cfgStr := req.ConfigValue.ValueString()
29+
30+
var stateMappings map[string]interface{}
31+
var cfgMappings map[string]interface{}
32+
33+
// No error checking, schema validation ensures this is valid json
34+
_ = json.Unmarshal([]byte(stateStr), &stateMappings)
35+
_ = json.Unmarshal([]byte(cfgStr), &cfgMappings)
36+
37+
if stateProps, ok := stateMappings["properties"]; ok {
38+
cfgProps, ok := cfgMappings["properties"]
39+
if !ok {
40+
resp.RequiresReplace = true
41+
return
42+
}
43+
44+
requiresReplace, finalMappings, diags := p.modifyMappings(ctx, path.Root("mappings").AtMapKey("properties"), stateProps.(map[string]interface{}), cfgProps.(map[string]interface{}))
45+
resp.RequiresReplace = requiresReplace
46+
cfgMappings["properties"] = finalMappings
47+
resp.Diagnostics.Append(diags...)
48+
49+
planBytes, err := json.Marshal(cfgMappings)
50+
if err != nil {
51+
resp.Diagnostics.AddAttributeError(req.Path, "Failed to marshal final mappings", err.Error())
52+
return
53+
}
54+
55+
resp.PlanValue = basetypes.NewStringValue(string(planBytes))
56+
}
57+
}
58+
59+
func (p mappingsPlanModifier) modifyMappings(ctx context.Context, initialPath path.Path, old map[string]interface{}, new map[string]interface{}) (bool, map[string]interface{}, diag.Diagnostics) {
60+
var diags diag.Diagnostics
61+
for k, v := range old {
62+
oldFieldSettings := v.(map[string]interface{})
63+
newFieldSettings, ok := new[k]
64+
currentPath := initialPath.AtMapKey(k)
65+
// When field is removed, it'll be ignored in elasticsearch
66+
if !ok {
67+
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")
68+
new[k] = v
69+
continue
70+
}
71+
newSettings := newFieldSettings.(map[string]interface{})
72+
// check if the "type" field exists and match with new one
73+
if s, ok := oldFieldSettings["type"]; ok {
74+
if ns, ok := newSettings["type"]; ok {
75+
if !reflect.DeepEqual(s, ns) {
76+
return true, new, diags
77+
}
78+
continue
79+
} else {
80+
return true, new, diags
81+
}
82+
}
83+
84+
// if we have "mapping" field, let's call ourself to check again
85+
if s, ok := oldFieldSettings["properties"]; ok {
86+
currentPath = currentPath.AtMapKey("properties")
87+
if ns, ok := newSettings["properties"]; ok {
88+
requiresReplace, newProperties, d := p.modifyMappings(ctx, currentPath, s.(map[string]interface{}), ns.(map[string]interface{}))
89+
diags.Append(d...)
90+
newSettings["properties"] = newProperties
91+
if requiresReplace {
92+
return true, new, diags
93+
}
94+
} else {
95+
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")
96+
newSettings["properties"] = s
97+
}
98+
}
99+
}
100+
101+
return false, new, diags
102+
}
103+
104+
func (p mappingsPlanModifier) Description(_ context.Context) string {
105+
return "Preserves existing mappings which don't exist in config"
106+
}
107+
108+
func (p mappingsPlanModifier) MarkdownDescription(ctx context.Context) string {
109+
return p.Description(ctx)
110+
}
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
package index
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/diag"
9+
"github.com/hashicorp/terraform-plugin-framework/path"
10+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
11+
"github.com/hashicorp/terraform-plugin-framework/types"
12+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func mapToJsonStringValue(t *testing.T, m map[string]interface{}) basetypes.StringValue {
17+
mBytes, err := json.Marshal(m)
18+
require.NoError(t, err)
19+
20+
return types.StringValue(string(mBytes))
21+
}
22+
23+
func Test_PlanModifyString(t *testing.T) {
24+
t.Parallel()
25+
26+
tests := []struct {
27+
name string
28+
stateMappings basetypes.StringValue
29+
configMappings basetypes.StringValue
30+
expectedPlanMappings basetypes.StringValue
31+
expectedDiags diag.Diagnostics
32+
expectedRequiresReplace bool
33+
}{
34+
{
35+
name: "should do nothing if the state value is unknown",
36+
stateMappings: basetypes.NewStringUnknown(),
37+
configMappings: basetypes.NewStringValue("{}"),
38+
},
39+
{
40+
name: "should do nothing if the state value is null",
41+
stateMappings: basetypes.NewStringNull(),
42+
configMappings: basetypes.NewStringValue("{}"),
43+
},
44+
{
45+
name: "should do nothing if the config value is unknown",
46+
configMappings: basetypes.NewStringUnknown(),
47+
stateMappings: basetypes.NewStringValue("{}"),
48+
},
49+
{
50+
name: "should do nothing if the config value is null",
51+
configMappings: basetypes.NewStringNull(),
52+
stateMappings: basetypes.NewStringValue("{}"),
53+
},
54+
{
55+
name: "should do nothing if the state mappings do not define any properties",
56+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
57+
"not_properties": map[string]interface{}{
58+
"hello": "world",
59+
},
60+
}),
61+
configMappings: basetypes.NewStringValue("{}"),
62+
},
63+
{
64+
name: "requires replace if state mappings define properties but the config value does not",
65+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
66+
"properties": map[string]interface{}{
67+
"hello": "world",
68+
},
69+
}),
70+
configMappings: basetypes.NewStringValue("{}"),
71+
expectedRequiresReplace: true,
72+
},
73+
{
74+
name: "should not alter the final plan when a new field is added",
75+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
76+
"properties": map[string]interface{}{
77+
"field1": map[string]interface{}{
78+
"type": "string",
79+
},
80+
},
81+
}),
82+
configMappings: mapToJsonStringValue(t, map[string]interface{}{
83+
"properties": map[string]interface{}{
84+
"field1": map[string]interface{}{
85+
"type": "string",
86+
},
87+
"field2": map[string]interface{}{
88+
"type": "string",
89+
},
90+
},
91+
}),
92+
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{
93+
"properties": map[string]interface{}{
94+
"field1": map[string]interface{}{
95+
"type": "string",
96+
},
97+
"field2": map[string]interface{}{
98+
"type": "string",
99+
},
100+
},
101+
}),
102+
},
103+
{
104+
name: "requires replace when the type of an existing field is changed",
105+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
106+
"properties": map[string]interface{}{
107+
"field1": map[string]interface{}{
108+
"type": "string",
109+
},
110+
},
111+
}),
112+
configMappings: mapToJsonStringValue(t, map[string]interface{}{
113+
"properties": map[string]interface{}{
114+
"field1": map[string]interface{}{
115+
"type": "int",
116+
},
117+
},
118+
}),
119+
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{
120+
"properties": map[string]interface{}{
121+
"field1": map[string]interface{}{
122+
"type": "int",
123+
},
124+
},
125+
}),
126+
expectedRequiresReplace: true,
127+
},
128+
{
129+
name: "should add the removed field to the plan and include a warning when a field is removed from config",
130+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
131+
"properties": map[string]interface{}{
132+
"field1": map[string]interface{}{
133+
"type": "string",
134+
},
135+
"field2": map[string]interface{}{
136+
"type": "string",
137+
},
138+
},
139+
}),
140+
configMappings: mapToJsonStringValue(t, map[string]interface{}{
141+
"properties": map[string]interface{}{
142+
"field1": map[string]interface{}{
143+
"type": "string",
144+
},
145+
},
146+
}),
147+
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{
148+
"properties": map[string]interface{}{
149+
"field1": map[string]interface{}{
150+
"type": "string",
151+
},
152+
"field2": map[string]interface{}{
153+
"type": "string",
154+
},
155+
},
156+
}),
157+
expectedDiags: diag.Diagnostics{
158+
diag.NewAttributeWarningDiagnostic(
159+
path.Root("mappings"),
160+
`removing field [mappings["properties"]["field2"]] in mappings is ignored.`,
161+
"Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely",
162+
),
163+
},
164+
},
165+
{
166+
name: "should add the removed field to the plan and include a warning when a sub-field is removed from config",
167+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
168+
"properties": map[string]interface{}{
169+
"field1": map[string]interface{}{
170+
"properties": map[string]interface{}{
171+
"field2": map[string]interface{}{
172+
"type": "string",
173+
},
174+
},
175+
},
176+
},
177+
}),
178+
configMappings: mapToJsonStringValue(t, map[string]interface{}{
179+
"properties": map[string]interface{}{
180+
"field1": map[string]interface{}{
181+
"properties": map[string]interface{}{
182+
"field3": map[string]interface{}{
183+
"type": "string",
184+
},
185+
},
186+
},
187+
},
188+
}),
189+
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{
190+
"properties": map[string]interface{}{
191+
"field1": map[string]interface{}{
192+
"properties": map[string]interface{}{
193+
"field2": map[string]interface{}{
194+
"type": "string",
195+
},
196+
"field3": map[string]interface{}{
197+
"type": "string",
198+
},
199+
},
200+
},
201+
},
202+
}),
203+
expectedDiags: diag.Diagnostics{
204+
diag.NewAttributeWarningDiagnostic(
205+
path.Root("mappings"),
206+
`removing field [mappings["properties"]["field1"]["properties"]["field2"]] in mappings is ignored.`,
207+
"Elasticsearch will maintain the current field in it's mapping. Re-index to remove the field completely",
208+
),
209+
},
210+
},
211+
{
212+
name: "requires replace when a sub-fields type is changed",
213+
stateMappings: mapToJsonStringValue(t, map[string]interface{}{
214+
"properties": map[string]interface{}{
215+
"field1": map[string]interface{}{
216+
"properties": map[string]interface{}{
217+
"field2": map[string]interface{}{
218+
"type": "string",
219+
},
220+
},
221+
},
222+
},
223+
}),
224+
configMappings: mapToJsonStringValue(t, map[string]interface{}{
225+
"properties": map[string]interface{}{
226+
"field1": map[string]interface{}{
227+
"properties": map[string]interface{}{
228+
"field2": map[string]interface{}{
229+
"type": "int",
230+
},
231+
},
232+
},
233+
},
234+
}),
235+
expectedPlanMappings: mapToJsonStringValue(t, map[string]interface{}{
236+
"properties": map[string]interface{}{
237+
"field1": map[string]interface{}{
238+
"properties": map[string]interface{}{
239+
"field2": map[string]interface{}{
240+
"type": "int",
241+
},
242+
},
243+
},
244+
},
245+
}),
246+
expectedRequiresReplace: true,
247+
},
248+
}
249+
250+
for _, tt := range tests {
251+
t.Run(tt.name, func(t *testing.T) {
252+
modifier := mappingsPlanModifier{}
253+
resp := planmodifier.StringResponse{}
254+
modifier.PlanModifyString(context.Background(), planmodifier.StringRequest{
255+
ConfigValue: tt.configMappings,
256+
StateValue: tt.stateMappings,
257+
}, &resp)
258+
259+
require.Equal(t, tt.expectedDiags, resp.Diagnostics)
260+
require.Equal(t, tt.expectedPlanMappings, resp.PlanValue)
261+
require.Equal(t, tt.expectedRequiresReplace, resp.RequiresReplace)
262+
})
263+
}
264+
}

0 commit comments

Comments
 (0)