Skip to content

Commit b393e30

Browse files
committed
Disallow merging existing mapping field definitions in templates
This commit changes the merge strategy introduced in elastic#55607 and elastic#55982. Instead of overwriting these fields, we now prevent them from being merged with an exception when a user attempts to overwrite a field. As part of this, a more robust validation has been added. The existing validation checked whether templates (composable and component) were valid on their own, this new validation now checks that the composite template (mappings/settings/aliases) is valid. This means that when a composable template is added or updated, we confirm that it is valid with its component pieces. When a component template is updated we ensure that all composable templates that make use of the component template continue to be valid before allowing the component template to be updated. This change also necessitated changes in the tests, however, I have left tests that exercise mapping merging with nested object fields as `@AwaitsFix`, as we intend to change the behavior soon to allow merging in a recursive-with-replacement fashion (see: elastic#57393). I have added tests that check the new disallowing behavior in the meantime.
1 parent 7f201d7 commit b393e30

7 files changed

Lines changed: 339 additions & 49 deletions

File tree

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: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ static Map<String, Object> parseV2Mappings(String mappingsJson, List<CompressedX
572572
nonProperties = innerTemplateNonProperties;
573573

574574
if (maybeProperties != null) {
575-
properties = mergeIgnoringDots(properties, maybeProperties);
575+
properties = mergeFailingOnReplacement(properties, maybeProperties);
576576
}
577577
}
578578
}
@@ -587,7 +587,7 @@ static Map<String, Object> parseV2Mappings(String mappingsJson, List<CompressedX
587587
nonProperties = innerRequestNonProperties;
588588

589589
if (maybeRequestProperties != null) {
590-
properties = mergeIgnoringDots(properties, maybeRequestProperties);
590+
properties = mergeFailingOnReplacement(properties, maybeRequestProperties);
591591
}
592592
}
593593

@@ -687,18 +687,23 @@ private static Map<String, Object> dedupDynamicTemplates(Map<String, Object> map
687687
}
688688

689689
/**
690-
* Add the objects in the second map to the first, where the keys in the {@code second} map have
691-
* higher predecence and overwrite the keys in the {@code first} map. In the event of a key with
692-
* a dot in it (ie, "foo.bar"), the keys are treated as only the prefix counting towards
693-
* equality. If the {@code second} map has a key such as "foo", all keys starting from "foo." in
694-
* the {@code first} map are discarded.
690+
* Add the objects in the second map to the first, A duplicated field is treated as illegal and
691+
* an exception is thrown.
695692
*/
696-
static Map<String, Object> mergeIgnoringDots(Map<String, Object> first, Map<String, Object> second) {
693+
static Map<String, Object> mergeFailingOnReplacement(Map<String, Object> first, Map<String, Object> second) {
697694
Objects.requireNonNull(first, "merging requires two non-null maps but the first map was null");
698695
Objects.requireNonNull(second, "merging requires two non-null maps but the second map was null");
699696
Map<String, Object> results = new HashMap<>(first);
700697
Set<String> prefixes = second.keySet().stream().map(MetadataCreateIndexService::prefix).collect(Collectors.toSet());
701-
results.keySet().removeIf(k -> prefixes.contains(prefix(k)));
698+
List<String> matchedPrefixes = new ArrayList<>();
699+
results.keySet().forEach(k -> {
700+
if (prefixes.contains(prefix(k))) {
701+
matchedPrefixes.add(k);
702+
}
703+
});
704+
if (matchedPrefixes.size() > 0) {
705+
throw new IllegalArgumentException("mapping fields " + matchedPrefixes + " cannot be replaced during template composition");
706+
}
702707
results.putAll(second);
703708
return results;
704709
}

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

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

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

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

427458
// Mappings in index templates don't include _doc, so update the mappings to include this single type
428459
if (stringMappings != null) {
@@ -441,6 +472,17 @@ public ClusterState addIndexTemplateV2(final ClusterState currentState, final bo
441472
}
442473

443474
validate(name, finalIndexTemplate);
475+
476+
// Finally, right before adding the template, we need to ensure that the composite settings,
477+
// mappings, and aliases are valid after it's been composed with the component templates
478+
try {
479+
validateCompositeTemplate(currentState, name, finalIndexTemplate, indicesService, xContentRegistry);
480+
} catch (Exception e) {
481+
throw new IllegalArgumentException("composable template [" + name +
482+
"] template after composition " +
483+
(finalIndexTemplate.composedOf().size() > 0 ? "with component templates " + finalIndexTemplate.composedOf() + " " : "") +
484+
"is invalid", e);
485+
}
444486
logger.info("adding index template [{}]", name);
445487
return ClusterState.builder(currentState)
446488
.metadata(Metadata.builder(currentState.metadata()).put(name, finalIndexTemplate))
@@ -803,7 +845,6 @@ public static List<CompressedXContent> resolveMappings(final ClusterState state,
803845
return List.of();
804846
}
805847
final Map<String, ComponentTemplate> componentTemplates = state.metadata().componentTemplates();
806-
// TODO: more fine-grained merging of component template mappings, ie, merge fields as distint entities
807848
List<CompressedXContent> mappings = template.composedOf().stream()
808849
.map(componentTemplates::get)
809850
.filter(Objects::nonNull)
@@ -910,6 +951,77 @@ public static List<Map<String, AliasMetadata>> resolveAliases(final Metadata met
910951
return Collections.unmodifiableList(aliases);
911952
}
912953

954+
/**
955+
* Given a state and a composable template, validate that the final composite template
956+
* generated by the composable template and all of its component templates contains valid
957+
* settings, mappings, and aliases.
958+
*/
959+
private static void validateCompositeTemplate(final ClusterState state,
960+
final String templateName,
961+
final ComposableIndexTemplate template,
962+
final IndicesService indicesService,
963+
final NamedXContentRegistry xContentRegistry) throws Exception {
964+
final ClusterState stateWithTemplate = ClusterState.builder(state)
965+
.metadata(Metadata.builder(state.metadata()).put(templateName, template))
966+
.build();
967+
968+
Index createdIndex = null;
969+
final String temporaryIndexName = "validate-template-" + UUIDs.randomBase64UUID().toLowerCase(Locale.ROOT);
970+
try {
971+
Settings resolvedSettings = resolveSettings(stateWithTemplate.metadata(), templateName);
972+
973+
// use the provided values, otherwise just pick valid dummy values
974+
int dummyPartitionSize = IndexMetadata.INDEX_ROUTING_PARTITION_SIZE_SETTING.get(resolvedSettings);
975+
int dummyShards = resolvedSettings.getAsInt(IndexMetadata.SETTING_NUMBER_OF_SHARDS,
976+
dummyPartitionSize == 1 ? 1 : dummyPartitionSize + 1);
977+
int shardReplicas = resolvedSettings.getAsInt(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0);
978+
979+
980+
//create index service for parsing and validating "mappings"
981+
Settings dummySettings = Settings.builder()
982+
.put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT)
983+
.put(resolvedSettings)
984+
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, dummyShards)
985+
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, shardReplicas)
986+
.put(IndexMetadata.SETTING_INDEX_UUID, UUIDs.randomBase64UUID())
987+
.build();
988+
989+
// Validate index metadata (settings)
990+
final ClusterState stateWithIndex = ClusterState.builder(stateWithTemplate)
991+
.metadata(Metadata.builder(stateWithTemplate.metadata())
992+
.put(IndexMetadata.builder(temporaryIndexName).settings(dummySettings))
993+
.build())
994+
.build();
995+
final IndexMetadata tmpIndexMetadata = stateWithIndex.metadata().index(temporaryIndexName);
996+
IndexService dummyIndexService = indicesService.createIndex(tmpIndexMetadata, Collections.emptyList(), false);
997+
createdIndex = dummyIndexService.index();
998+
999+
// Validate aliases
1000+
MetadataCreateIndexService.resolveAndValidateAliases(temporaryIndexName, Collections.emptySet(),
1001+
MetadataIndexTemplateService.resolveAliases(stateWithIndex.metadata(), templateName), stateWithIndex.metadata(),
1002+
new AliasValidator(),
1003+
// the context is only used for validation so it's fine to pass fake values for the
1004+
// shard id and the current timestamp
1005+
xContentRegistry, dummyIndexService.newQueryShardContext(0, null, () -> 0L, null));
1006+
1007+
// Parse mappings to ensure they are valid after being composed
1008+
List<CompressedXContent> mappings = resolveMappings(stateWithIndex, templateName);
1009+
Map<String, Object> finalMappings = MetadataCreateIndexService.parseV2Mappings("{}", mappings, xContentRegistry);
1010+
MapperService dummyMapperService = dummyIndexService.mapperService();
1011+
if (finalMappings.isEmpty() == false) {
1012+
assert finalMappings.size() == 1 : finalMappings;
1013+
// TODO: Eventually change this to:
1014+
// dummyMapperService.merge(MapperService.SINGLE_MAPPING_NAME, mapping, MergeReason.INDEX_TEMPLATE);
1015+
dummyMapperService.merge(MapperService.SINGLE_MAPPING_NAME, finalMappings, MergeReason.MAPPING_UPDATE);
1016+
}
1017+
1018+
} finally {
1019+
if (createdIndex != null) {
1020+
indicesService.removeIndex(createdIndex, NO_LONGER_ASSIGNED, "created for validating template addition");
1021+
}
1022+
}
1023+
}
1024+
9131025
private static void validateTemplate(Settings validateSettings, String mappings,
9141026
IndicesService indicesService, NamedXContentRegistry xContentRegistry) throws Exception {
9151027
// First check to see if mappings are valid XContent

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
@@ -103,7 +103,7 @@ public static ComposableIndexTemplate randomInstance() {
103103
private static Map<String, AliasMetadata> randomAliases() {
104104
String aliasName = randomAlphaOfLength(5);
105105
AliasMetadata aliasMeta = AliasMetadata.builder(aliasName)
106-
.filter(Collections.singletonMap(randomAlphaOfLength(2), randomAlphaOfLength(2)))
106+
.filter("{\"term\":{\"year\":" + randomIntBetween(1, 3000) + "}}")
107107
.routing(randomBoolean() ? null : randomAlphaOfLength(3))
108108
.isHidden(randomBoolean() ? null : randomBoolean())
109109
.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
@@ -946,6 +946,7 @@ public void testDeprecateTranslogRetentionSettings() {
946946
}
947947

948948
@SuppressWarnings("unchecked")
949+
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/57393")
949950
public void testMappingsMergingIsSmart() throws Exception {
950951
Template ctt1 = new Template(null,
951952
new CompressedXContent("{\"_doc\":{\"_source\":{\"enabled\": false},\"_meta\":{\"ct1\":{\"ver\": \"text\"}}," +
@@ -1010,6 +1011,7 @@ public void testMappingsMergingIsSmart() throws Exception {
10101011
}
10111012

10121013
@SuppressWarnings("unchecked")
1014+
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/pull/57393")
10131015
public void testMappingsMergingHandlesDots() throws Exception {
10141016
Template ctt1 = new Template(null,
10151017
new CompressedXContent("{\"_doc\":{\"properties\":{\"foo\":{\"properties\":{\"bar\":{\"type\": \"long\"}}}}}}"), null);
@@ -1044,33 +1046,31 @@ public void testMappingsMergingHandlesDots() throws Exception {
10441046
equalTo(Collections.singletonMap("properties", Collections.singletonMap("bar", Collections.singletonMap("type", "long")))));
10451047
}
10461048

1047-
public void testMergeIgnoringDots() throws Exception {
1048-
Map<String, Object> first = new HashMap<>();
1049-
first.put("foo", Collections.singletonMap("type", "long"));
1050-
Map<String, Object> second = new HashMap<>();
1051-
second.put("foo.bar", Collections.singletonMap("type", "long"));
1052-
Map<String, Object> results = MetadataCreateIndexService.mergeIgnoringDots(first, second);
1053-
assertThat(results, equalTo(second));
1054-
1055-
results = MetadataCreateIndexService.mergeIgnoringDots(second, first);
1056-
assertThat(results, equalTo(first));
1057-
1058-
second.clear();
1059-
Map<String, Object> inner = new HashMap<>();
1060-
inner.put("type", "text");
1061-
inner.put("analyzer", "english");
1062-
second.put("foo", inner);
1063-
1064-
results = MetadataCreateIndexService.mergeIgnoringDots(first, second);
1065-
assertThat(results, equalTo(second));
1066-
1067-
first.put("baz", 3);
1068-
second.put("egg", 7);
1069-
1070-
results = MetadataCreateIndexService.mergeIgnoringDots(first, second);
1071-
Map<String, Object> expected = new HashMap<>(second);
1072-
expected.put("baz", 3);
1073-
assertThat(results, equalTo(expected));
1049+
public void testMappingsMergingThrowsOnConflictDots() throws Exception {
1050+
Template ctt1 = new Template(null,
1051+
new CompressedXContent("{\"_doc\":{\"properties\":{\"foo\":{\"properties\":{\"bar\":{\"type\": \"long\"}}}}}}"), null);
1052+
Template ctt2 = new Template(null,
1053+
new CompressedXContent("{\"_doc\":{\"properties\":{\"foo.bar\":{\"type\": \"text\",\"analyzer\":\"english\"}}}}"), null);
1054+
1055+
ComponentTemplate ct1 = new ComponentTemplate(ctt1, null, null);
1056+
ComponentTemplate ct2 = new ComponentTemplate(ctt2, null, null);
1057+
1058+
ComposableIndexTemplate template = new ComposableIndexTemplate(Collections.singletonList("index"),
1059+
null, Arrays.asList("ct2", "ct1"), null, null, null, null);
1060+
1061+
ClusterState state = ClusterState.builder(ClusterState.EMPTY_STATE)
1062+
.metadata(Metadata.builder(Metadata.EMPTY_METADATA)
1063+
.put("ct1", ct1)
1064+
.put("ct2", ct2)
1065+
.put("index-template", template)
1066+
.build())
1067+
.build();
1068+
1069+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
1070+
() -> MetadataCreateIndexService.resolveV2Mappings("{}", state,
1071+
"index-template", new NamedXContentRegistry(Collections.emptyList())));
1072+
1073+
assertThat(e.getMessage(), containsString("mapping fields [foo.bar] cannot be replaced during template composition"));
10741074
}
10751075

10761076
@SuppressWarnings("unchecked")

0 commit comments

Comments
 (0)