Skip to content

Commit eda4da1

Browse files
[7.8] Disallow merging existing mapping field definitions in templates (#57701) (#57823)
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent 2cfe12f commit eda4da1

File tree

7 files changed

+343
-49
lines changed

7 files changed

+343
-49
lines changed

rest-api-spec/src/main/resources/rest-api-spec/test/indices.put_index_template/15_composition.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
number_of_replicas: 1
1515
mappings:
1616
properties:
17-
field2:
17+
field1:
1818
type: text
1919
aliases:
2020
aliasname:
@@ -47,9 +47,8 @@
4747
index.number_of_shards: 2
4848
mappings:
4949
properties:
50-
field:
51-
type: keyword
52-
ignore_above: 255
50+
field3:
51+
type: integer
5352
aliases:
5453
my_alias: {}
5554
aliasname:
@@ -78,8 +77,9 @@
7877
- match: {bar-baz.settings.index.number_of_shards: "2"}
7978
- match: {bar-baz.settings.index.number_of_replicas: "0"}
8079
- match: {bar-baz.settings.index.priority: "17"}
81-
- match: {bar-baz.mappings.properties.field: {type: keyword, ignore_above: 255}}
80+
- match: {bar-baz.mappings.properties.field1: {type: text}}
8281
- match: {bar-baz.mappings.properties.field2: {type: keyword}}
82+
- match: {bar-baz.mappings.properties.field3: {type: integer}}
8383
- match: {bar-baz.mappings.properties.foo: {type: keyword}}
8484
- match: {bar-baz.aliases.aliasname: {filter: {match_all: {}}}}
8585
- match: {bar-baz.aliases.my_alias: {}}

server/src/main/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexService.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ static Map<String, Map<String, Object>> parseV2Mappings(String mappingsJson, Lis
590590
nonProperties = innerTemplateNonProperties;
591591

592592
if (maybeProperties != null) {
593-
properties = mergeIgnoringDots(properties, maybeProperties);
593+
properties = mergeFailingOnReplacement(properties, maybeProperties);
594594
}
595595
}
596596
}
@@ -605,7 +605,7 @@ static Map<String, Map<String, Object>> parseV2Mappings(String mappingsJson, Lis
605605
nonProperties = innerRequestNonProperties;
606606

607607
if (maybeRequestProperties != null) {
608-
properties = mergeIgnoringDots(properties, maybeRequestProperties);
608+
properties = mergeFailingOnReplacement(properties, maybeRequestProperties);
609609
}
610610
}
611611

@@ -705,18 +705,18 @@ private static Map<String, Object> dedupDynamicTemplates(Map<String, Object> map
705705
}
706706

707707
/**
708-
* Add the objects in the second map to the first, where the keys in the {@code second} map have
709-
* higher predecence and overwrite the keys in the {@code first} map. In the event of a key with
710-
* a dot in it (ie, "foo.bar"), the keys are treated as only the prefix counting towards
711-
* equality. If the {@code second} map has a key such as "foo", all keys starting from "foo." in
712-
* the {@code first} map are discarded.
708+
* Add the objects in the second map to the first, A duplicated field is treated as illegal and
709+
* an exception is thrown.
713710
*/
714-
static Map<String, Object> mergeIgnoringDots(Map<String, Object> first, Map<String, Object> second) {
711+
static Map<String, Object> mergeFailingOnReplacement(Map<String, Object> first, Map<String, Object> second) {
715712
Objects.requireNonNull(first, "merging requires two non-null maps but the first map was null");
716713
Objects.requireNonNull(second, "merging requires two non-null maps but the second map was null");
717714
Map<String, Object> results = new HashMap<>(first);
718715
Set<String> prefixes = second.keySet().stream().map(MetadataCreateIndexService::prefix).collect(Collectors.toSet());
719-
results.keySet().removeIf(k -> prefixes.contains(prefix(k)));
716+
List<String> matchedPrefixes = results.keySet().stream().filter(k -> prefixes.contains(prefix(k))).collect(Collectors.toList());
717+
if (matchedPrefixes.size() > 0) {
718+
throw new IllegalArgumentException("mapping fields " + matchedPrefixes + " cannot be replaced during template composition");
719+
}
720720
results.putAll(second);
721721
return results;
722722
}

server/src/main/java/org/elasticsearch/cluster/metadata/MetadataIndexTemplateService.java

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,15 +196,21 @@ ClusterState addComponentTemplate(final ClusterState currentState, final boolean
196196

197197
validateTemplate(finalSettings, stringMappings, indicesService, xContentRegistry);
198198

199+
// Collect all the composable (index) templates that use this component template, we'll use
200+
// this for validating that they're still going to be valid after this component template
201+
// has been updated
202+
final Map<String, ComposableIndexTemplate> templatesUsingComponent = currentState.metadata().templatesV2().entrySet().stream()
203+
.filter(e -> e.getValue().composedOf().contains(name))
204+
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
205+
199206
// if we're updating a component template, let's check if it's part of any V2 template that will yield the CT update invalid
200207
if (create == false && finalSettings != null) {
201208
// if the CT is specifying the `index.hidden` setting it cannot be part of any global template
202209
if (IndexMetadata.INDEX_HIDDEN_SETTING.exists(finalSettings)) {
203-
Map<String, ComposableIndexTemplate> existingTemplates = currentState.metadata().templatesV2();
204210
List<String> globalTemplatesThatUseThisComponent = new ArrayList<>();
205-
for (Map.Entry<String, ComposableIndexTemplate> entry : existingTemplates.entrySet()) {
211+
for (Map.Entry<String, ComposableIndexTemplate> entry : templatesUsingComponent.entrySet()) {
206212
ComposableIndexTemplate templateV2 = entry.getValue();
207-
if (templateV2.composedOf().contains(name) && templateV2.indexPatterns().stream().anyMatch(Regex::isMatchAllPattern)) {
213+
if (templateV2.indexPatterns().stream().anyMatch(Regex::isMatchAllPattern)) {
208214
// global templates don't support configuring the `index.hidden` setting so we don't need to resolve the settings as
209215
// no other component template can remove this setting from the resolved settings, so just invalidate this update
210216
globalTemplatesThatUseThisComponent.add(entry.getKey());
@@ -234,6 +240,32 @@ ClusterState addComponentTemplate(final ClusterState currentState, final boolean
234240
stringMappings == null ? null : new CompressedXContent(stringMappings), template.template().aliases());
235241
final ComponentTemplate finalComponentTemplate = new ComponentTemplate(finalTemplate, template.version(), template.metadata());
236242
validate(name, finalComponentTemplate);
243+
244+
if (templatesUsingComponent.size() > 0) {
245+
ClusterState tempStateWithComponentTemplateAdded = ClusterState.builder(currentState)
246+
.metadata(Metadata.builder(currentState.metadata()).put(name, finalComponentTemplate))
247+
.build();
248+
Exception validationFailure = null;
249+
for (Map.Entry<String, ComposableIndexTemplate> entry : templatesUsingComponent.entrySet()) {
250+
final String composableTemplateName = entry.getKey();
251+
final ComposableIndexTemplate composableTemplate = entry.getValue();
252+
try {
253+
validateCompositeTemplate(tempStateWithComponentTemplateAdded, composableTemplateName,
254+
composableTemplate, indicesService, xContentRegistry);
255+
} catch (Exception e) {
256+
if (validationFailure == null) {
257+
validationFailure = new IllegalArgumentException("updating component template [" + name +
258+
"] results in invalid composable template [" + composableTemplateName + "] after templates are merged", e);
259+
} else {
260+
validationFailure.addSuppressed(e);
261+
}
262+
}
263+
}
264+
if (validationFailure != null) {
265+
throw validationFailure;
266+
}
267+
}
268+
237269
logger.info("adding component template [{}]", name);
238270
return ClusterState.builder(currentState)
239271
.metadata(Metadata.builder(currentState.metadata()).put(name, finalComponentTemplate))
@@ -385,7 +417,6 @@ public ClusterState addIndexTemplateV2(final ClusterState currentState, final bo
385417
// adjusted (to add _doc) and it should be validated
386418
CompressedXContent mappings = innerTemplate.mappings();
387419
String stringMappings = mappings == null ? null : mappings.string();
388-
validateTemplate(finalSettings, stringMappings, indicesService, xContentRegistry);
389420

390421
// Mappings in index templates don't include _doc, so update the mappings to include this single type
391422
if (stringMappings != null) {
@@ -404,6 +435,17 @@ public ClusterState addIndexTemplateV2(final ClusterState currentState, final bo
404435
}
405436

406437
validate(name, finalIndexTemplate);
438+
439+
// Finally, right before adding the template, we need to ensure that the composite settings,
440+
// mappings, and aliases are valid after it's been composed with the component templates
441+
try {
442+
validateCompositeTemplate(currentState, name, finalIndexTemplate, indicesService, xContentRegistry);
443+
} catch (Exception e) {
444+
throw new IllegalArgumentException("composable template [" + name +
445+
"] template after composition " +
446+
(finalIndexTemplate.composedOf().size() > 0 ? "with component templates " + finalIndexTemplate.composedOf() + " " : "") +
447+
"is invalid", e);
448+
}
407449
logger.info("adding index template [{}]", name);
408450
return ClusterState.builder(currentState)
409451
.metadata(Metadata.builder(currentState.metadata()).put(name, finalIndexTemplate))
@@ -748,7 +790,6 @@ public static List<CompressedXContent> resolveMappings(final ClusterState state,
748790
return Collections.emptyList();
749791
}
750792
final Map<String, ComponentTemplate> componentTemplates = state.metadata().componentTemplates();
751-
// TODO: more fine-grained merging of component template mappings, ie, merge fields as distint entities
752793
List<CompressedXContent> mappings = template.composedOf().stream()
753794
.map(componentTemplates::get)
754795
.filter(Objects::nonNull)
@@ -855,6 +896,72 @@ public static List<Map<String, AliasMetadata>> resolveAliases(final Metadata met
855896
return Collections.unmodifiableList(aliases);
856897
}
857898

899+
/**
900+
* Given a state and a composable template, validate that the final composite template
901+
* generated by the composable template and all of its component templates contains valid
902+
* settings, mappings, and aliases.
903+
*/
904+
private static void validateCompositeTemplate(final ClusterState state,
905+
final String templateName,
906+
final ComposableIndexTemplate template,
907+
final IndicesService indicesService,
908+
final NamedXContentRegistry xContentRegistry) throws Exception {
909+
final ClusterState stateWithTemplate = ClusterState.builder(state)
910+
.metadata(Metadata.builder(state.metadata()).put(templateName, template))
911+
.build();
912+
913+
final String temporaryIndexName = "validate-template-" + UUIDs.randomBase64UUID().toLowerCase(Locale.ROOT);
914+
Settings resolvedSettings = resolveSettings(stateWithTemplate.metadata(), templateName);
915+
916+
// use the provided values, otherwise just pick valid dummy values
917+
int dummyPartitionSize = IndexMetadata.INDEX_ROUTING_PARTITION_SIZE_SETTING.get(resolvedSettings);
918+
int dummyShards = resolvedSettings.getAsInt(IndexMetadata.SETTING_NUMBER_OF_SHARDS,
919+
dummyPartitionSize == 1 ? 1 : dummyPartitionSize + 1);
920+
int shardReplicas = resolvedSettings.getAsInt(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0);
921+
922+
923+
// Create the final aggregate settings, which will be used to create the temporary index metadata to validate everything
924+
Settings finalResolvedSettings = Settings.builder()
925+
.put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT)
926+
.put(resolvedSettings)
927+
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, dummyShards)
928+
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, shardReplicas)
929+
.put(IndexMetadata.SETTING_INDEX_UUID, UUIDs.randomBase64UUID())
930+
.build();
931+
932+
// Validate index metadata (settings)
933+
final ClusterState stateWithIndex = ClusterState.builder(stateWithTemplate)
934+
.metadata(Metadata.builder(stateWithTemplate.metadata())
935+
.put(IndexMetadata.builder(temporaryIndexName).settings(finalResolvedSettings))
936+
.build())
937+
.build();
938+
final IndexMetadata tmpIndexMetadata = stateWithIndex.metadata().index(temporaryIndexName);
939+
indicesService.withTempIndexService(tmpIndexMetadata,
940+
tempIndexService -> {
941+
// Validate aliases
942+
MetadataCreateIndexService.resolveAndValidateAliases(temporaryIndexName, Collections.emptySet(),
943+
MetadataIndexTemplateService.resolveAliases(stateWithIndex.metadata(), templateName), stateWithIndex.metadata(),
944+
new AliasValidator(),
945+
// the context is only used for validation so it's fine to pass fake values for the
946+
// shard id and the current timestamp
947+
xContentRegistry, tempIndexService.newQueryShardContext(0, null, () -> 0L, null));
948+
949+
// Parse mappings to ensure they are valid after being composed
950+
List<CompressedXContent> mappings = resolveMappings(stateWithIndex, templateName);
951+
try {
952+
MapperService dummyMapperService = tempIndexService.mapperService();
953+
for (CompressedXContent mapping : mappings) {
954+
// TODO: Eventually change this to:
955+
// dummyMapperService.merge(MapperService.SINGLE_MAPPING_NAME, mapping, MergeReason.INDEX_TEMPLATE);
956+
dummyMapperService.merge(MapperService.SINGLE_MAPPING_NAME, mapping, MergeReason.MAPPING_UPDATE);
957+
}
958+
} catch (Exception e) {
959+
throw new IllegalArgumentException("invalid composite mappings for [" + templateName + "]", e);
960+
}
961+
return null;
962+
});
963+
}
964+
858965
private static void validateTemplate(Settings validateSettings, String mappings,
859966
IndicesService indicesService, NamedXContentRegistry xContentRegistry) throws Exception {
860967
validateTemplate(validateSettings, Collections.singletonMap(MapperService.SINGLE_MAPPING_NAME, mappings),

server/src/test/java/org/elasticsearch/cluster/metadata/ComponentTemplateTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public static ComponentTemplate randomInstance() {
8888
public static Map<String, AliasMetadata> randomAliases() {
8989
String aliasName = randomAlphaOfLength(5);
9090
AliasMetadata aliasMeta = AliasMetadata.builder(aliasName)
91-
.filter(Collections.singletonMap(randomAlphaOfLength(2), randomAlphaOfLength(2)))
91+
.filter("{\"term\":{\"year\":" + randomIntBetween(1, 3000) + "}}")
9292
.routing(randomBoolean() ? null : randomAlphaOfLength(3))
9393
.isHidden(randomBoolean() ? null : randomBoolean())
9494
.writeIndex(randomBoolean() ? null : randomBoolean())

server/src/test/java/org/elasticsearch/cluster/metadata/ComposableIndexTemplateTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public static ComposableIndexTemplate randomInstance() {
100100
private static Map<String, AliasMetadata> randomAliases() {
101101
String aliasName = randomAlphaOfLength(5);
102102
AliasMetadata aliasMeta = AliasMetadata.builder(aliasName)
103-
.filter(Collections.singletonMap(randomAlphaOfLength(2), randomAlphaOfLength(2)))
103+
.filter("{\"term\":{\"year\":" + randomIntBetween(1, 3000) + "}}")
104104
.routing(randomBoolean() ? null : randomAlphaOfLength(3))
105105
.isHidden(randomBoolean() ? null : randomBoolean())
106106
.writeIndex(randomBoolean() ? null : randomBoolean())

server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,7 @@ public void testValidateTranslogRetentionSettings() {
10021002
}
10031003

10041004
@SuppressWarnings("unchecked")
1005+
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/57393")
10051006
public void testMappingsMergingIsSmart() throws Exception {
10061007
Template ctt1 = new Template(null,
10071008
new CompressedXContent("{\"_doc\":{\"_source\":{\"enabled\": false},\"_meta\":{\"ct1\":{\"ver\": \"text\"}}," +
@@ -1066,6 +1067,7 @@ public void testMappingsMergingIsSmart() throws Exception {
10661067
}
10671068

10681069
@SuppressWarnings("unchecked")
1070+
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/57393")
10691071
public void testMappingsMergingHandlesDots() throws Exception {
10701072
Template ctt1 = new Template(null,
10711073
new CompressedXContent("{\"_doc\":{\"properties\":{\"foo\":{\"properties\":{\"bar\":{\"type\": \"long\"}}}}}}"), null);
@@ -1100,33 +1102,31 @@ public void testMappingsMergingHandlesDots() throws Exception {
11001102
equalTo(Collections.singletonMap("properties", Collections.singletonMap("bar", Collections.singletonMap("type", "long")))));
11011103
}
11021104

1103-
public void testMergeIgnoringDots() throws Exception {
1104-
Map<String, Object> first = new HashMap<>();
1105-
first.put("foo", Collections.singletonMap("type", "long"));
1106-
Map<String, Object> second = new HashMap<>();
1107-
second.put("foo.bar", Collections.singletonMap("type", "long"));
1108-
Map<String, Object> results = MetadataCreateIndexService.mergeIgnoringDots(first, second);
1109-
assertThat(results, equalTo(second));
1110-
1111-
results = MetadataCreateIndexService.mergeIgnoringDots(second, first);
1112-
assertThat(results, equalTo(first));
1113-
1114-
second.clear();
1115-
Map<String, Object> inner = new HashMap<>();
1116-
inner.put("type", "text");
1117-
inner.put("analyzer", "english");
1118-
second.put("foo", inner);
1119-
1120-
results = MetadataCreateIndexService.mergeIgnoringDots(first, second);
1121-
assertThat(results, equalTo(second));
1122-
1123-
first.put("baz", 3);
1124-
second.put("egg", 7);
1125-
1126-
results = MetadataCreateIndexService.mergeIgnoringDots(first, second);
1127-
Map<String, Object> expected = new HashMap<>(second);
1128-
expected.put("baz", 3);
1129-
assertThat(results, equalTo(expected));
1105+
public void testMappingsMergingThrowsOnConflictDots() throws Exception {
1106+
Template ctt1 = new Template(null,
1107+
new CompressedXContent("{\"_doc\":{\"properties\":{\"foo\":{\"properties\":{\"bar\":{\"type\": \"long\"}}}}}}"), null);
1108+
Template ctt2 = new Template(null,
1109+
new CompressedXContent("{\"_doc\":{\"properties\":{\"foo.bar\":{\"type\": \"text\",\"analyzer\":\"english\"}}}}"), null);
1110+
1111+
ComponentTemplate ct1 = new ComponentTemplate(ctt1, null, null);
1112+
ComponentTemplate ct2 = new ComponentTemplate(ctt2, null, null);
1113+
1114+
ComposableIndexTemplate template = new ComposableIndexTemplate(Collections.singletonList("index"),
1115+
null, Arrays.asList("ct2", "ct1"), null, null, null);
1116+
1117+
ClusterState state = ClusterState.builder(ClusterState.EMPTY_STATE)
1118+
.metadata(Metadata.builder(Metadata.EMPTY_METADATA)
1119+
.put("ct1", ct1)
1120+
.put("ct2", ct2)
1121+
.put("index-template", template)
1122+
.build())
1123+
.build();
1124+
1125+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
1126+
() -> MetadataCreateIndexService.resolveV2Mappings("{}", state,
1127+
"index-template", new NamedXContentRegistry(Collections.emptyList())));
1128+
1129+
assertThat(e.getMessage(), containsString("mapping fields [foo.bar] cannot be replaced during template composition"));
11301130
}
11311131

11321132
@SuppressWarnings("unchecked")

0 commit comments

Comments
 (0)