Browse Source

Index template: Add created_date and modified_date (#132083)

Add new system-managed properties to index templates:
- `created_date`: when the template with a given ID was created
- `modified_date`: when the template was updated
Szymon Bialkowski 2 months ago
parent
commit
6b22eb5fbc

+ 5 - 0
docs/changelog/132083.yaml

@@ -0,0 +1,5 @@
+pr: 132083
+summary: "Index template: Add created_date and modified_date"
+area: Ingest Node
+type: enhancement
+issues: []

+ 23 - 4
modules/data-streams/src/test/java/org/elasticsearch/datastreams/MetadataIndexTemplateServiceTests.java

@@ -88,7 +88,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 .dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
                 .build();
             project = service.addIndexTemplateV2(project, false, "1", indexTemplate);
-            assertThat(project.templatesV2().get("1"), equalTo(indexTemplate));
+            var actualTemplate = project.templatesV2().get("1");
+            assertTemplateActualIsExpected(actualTemplate, indexTemplate);
         }
         {
             // Routing path defined in component template
@@ -106,7 +107,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 .dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
                 .build();
             project = service.addIndexTemplateV2(project, false, "1", indexTemplate);
-            assertThat(project.templatesV2().get("1"), equalTo(indexTemplate));
+            var actualTemplate = project.templatesV2().get("1");
+            assertTemplateActualIsExpected(actualTemplate, indexTemplate);
         }
         {
             // Routing path defined in index template
@@ -118,7 +120,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 .dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
                 .build();
             var project = service.addIndexTemplateV2(initialProject, false, "1", indexTemplate);
-            assertThat(project.templatesV2().get("1"), equalTo(indexTemplate));
+            var actualTemplate = project.templatesV2().get("1");
+            assertTemplateActualIsExpected(actualTemplate, indexTemplate);
         }
         {
             // Routing fetched from mapping in index template
@@ -132,7 +135,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 .dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
                 .build();
             var project = service.addIndexTemplateV2(initialProject, false, "1", indexTemplate);
-            assertThat(project.templatesV2().get("1"), equalTo(indexTemplate));
+            var actualTemplate = project.templatesV2().get("1");
+            assertTemplateActualIsExpected(actualTemplate, indexTemplate);
         }
     }
 
@@ -190,6 +194,21 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         }
     }
 
+    private void assertTemplateActualIsExpected(final ComposableIndexTemplate actual, final ComposableIndexTemplate expected) {
+        // make sure arguments passed in right order
+        assertTrue(actual.createdDateMillis().isPresent());
+        assertTrue(actual.modifiedDateMillis().isPresent());
+        assertTrue(expected.createdDateMillis().isEmpty());
+        assertTrue(expected.modifiedDateMillis().isEmpty());
+
+        var expectedWithDates = expected.toBuilder()
+            // can't inject timing into creation so carrying over the dates from created template
+            .createdDate(actual.createdDateMillis().orElse(null))
+            .modifiedDate(actual.modifiedDateMillis().orElse(null))
+            .build();
+        assertThat(actual, equalTo(expectedWithDates));
+    }
+
     private MetadataIndexTemplateService getMetadataIndexTemplateService() {
         var indicesService = getInstanceFromNode(IndicesService.class);
         var clusterService = getInstanceFromNode(ClusterService.class);

+ 112 - 0
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.put_index_template/20_tracking.yml

@@ -0,0 +1,112 @@
+setup:
+  - requires:
+      test_runner_features: [capabilities, contains]
+      capabilities:
+        - method: PUT
+          path: /_index_template/{id}
+          capabilities: [ index_template_tracking_info ]
+      reason: "Index templates have tracking info: modified_date and created_date"
+
+---
+"Test PUT setting created_date":
+  - do:
+      catch: bad_request
+      indices.put_index_template:
+        name: test_tracking
+        body:
+          index_patterns: [ "test-*" ]
+          template:
+            settings:
+              number_of_shards: 1
+          created_date: "2025-07-04T12:50:48.415Z"
+  - match: { status: 400 }
+  - contains: { error.reason: "[index_template] unknown field [created_date] did you mean [created_date_millis]?" }
+
+---
+"Test PUT setting created_date_millis":
+  - do:
+      catch: bad_request
+      indices.put_index_template:
+        name: test_tracking
+        body:
+          index_patterns: ["test-*"]
+          template:
+            settings:
+              number_of_shards: 1
+          created_date_millis: 0
+  - match: { status: 400 }
+  - match: { error.reason: "index_template [test_tracking] invalid, cause [provided a template property which is managed by the system: created_date]" }
+
+---
+"Test PUT setting modified_date":
+  - do:
+      catch: bad_request
+      indices.put_index_template:
+        name: test_tracking
+        body:
+          index_patterns: ["test-*"]
+          template:
+            settings:
+              number_of_shards: 1
+          modified_date: "2025-07-04T12:50:48.415Z"
+  - match: { status: 400 }
+  - contains: { error.reason: "[index_template] unknown field [modified_date] did you mean [modified_date_millis]?" }
+
+---
+"Test PUT setting modified_date_millis":
+  - do:
+      catch: bad_request
+      indices.put_index_template:
+        name: test_tracking
+        body:
+          index_patterns: ["test-*"]
+          template:
+            settings:
+              number_of_shards: 1
+          modified_date_millis: 0
+  - match: { status: 400 }
+  - match: { error.reason: "index_template [test_tracking] invalid, cause [provided a template property which is managed by the system: modified_date]" }
+
+---
+"Test update preserves created_date but updates modified_date":
+  - do:
+      indices.put_index_template:
+        name: test_tracking
+        body:
+          index_patterns: ["test-*"]
+          template:
+            settings:
+              number_of_shards: 1
+  - match: { acknowledged: true }
+
+  - do:
+      indices.get_index_template:
+        human: true
+        name: test_tracking
+  - set: { index_templates.0.index_template.created_date: first_created }
+  - set: { index_templates.0.index_template.created_date_millis: first_created_millis }
+  - set: { index_templates.0.index_template.modified_date: first_modified }
+  - set: { index_templates.0.index_template.modified_date_millis: first_modified_millis }
+  - match: { $first_created: "/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/" }
+  - match: { $first_modified: "/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/" }
+  - match: { $first_created: $first_modified }
+  - match: { $first_created_millis: $first_modified_millis }
+  - gte: { $first_created_millis: 0 }
+
+  - do:
+      indices.put_index_template:
+        name: test_tracking
+        body:
+          index_patterns: ["test-*"]
+          template:
+            settings:
+              number_of_shards: 2
+
+  - do:
+      indices.get_index_template:
+        human: true
+        name: test_tracking
+  - set: { index_templates.0.index_template.created_date: second_created }
+  - set: { index_templates.0.index_template.created_date_millis: second_created_millis }
+  - match: { $second_created: $first_created }
+  - match: { $second_created_millis: $first_created_millis }

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -355,6 +355,7 @@ public class TransportVersions {
     public static final TransportVersion TO_CHILD_BLOCK_JOIN_QUERY = def(9_133_0_00);
     public static final TransportVersion ML_INFERENCE_AI21_COMPLETION_ADDED = def(9_134_0_00);
     public static final TransportVersion TRANSPORT_NODE_USAGE_STATS_FOR_THREAD_POOLS_ACTION = def(9_135_0_00);
+    public static final TransportVersion INDEX_TEMPLATE_TRACKING_INFO = def(9_136_0_00);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 93 - 48
server/src/main/java/org/elasticsearch/cluster/metadata/ComposableIndexTemplate.java

@@ -40,6 +40,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 
 /**
  * An index template consists of a set of index patterns, an optional template, and a list of
@@ -47,6 +48,7 @@ import java.util.Objects;
  * index.
  */
 public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTemplate>, ToXContentObject {
+
     private static final ParseField INDEX_PATTERNS = new ParseField("index_patterns");
     private static final ParseField TEMPLATE = new ParseField("template");
     private static final ParseField PRIORITY = new ParseField("priority");
@@ -57,6 +59,10 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
     private static final ParseField ALLOW_AUTO_CREATE = new ParseField("allow_auto_create");
     private static final ParseField IGNORE_MISSING_COMPONENT_TEMPLATES = new ParseField("ignore_missing_component_templates");
     private static final ParseField DEPRECATED = new ParseField("deprecated");
+    private static final ParseField CREATED_DATE_MILLIS = new ParseField("created_date_millis");
+    private static final ParseField CREATED_DATE = new ParseField("created_date");
+    private static final ParseField MODIFIED_DATE_MILLIS = new ParseField("modified_date_millis");
+    private static final ParseField MODIFIED_DATE = new ParseField("modified_date");
     public static final CompressedXContent EMPTY_MAPPINGS;
     static {
         try {
@@ -70,18 +76,20 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
     public static final ConstructingObjectParser<ComposableIndexTemplate, Void> PARSER = new ConstructingObjectParser<>(
         "index_template",
         false,
-        a -> new ComposableIndexTemplate(
-            (List<String>) a[0],
-            (Template) a[1],
-            (List<String>) a[2],
-            (Long) a[3],
-            (Long) a[4],
-            (Map<String, Object>) a[5],
-            (DataStreamTemplate) a[6],
-            (Boolean) a[7],
-            (List<String>) a[8],
-            (Boolean) a[9]
-        )
+        a -> ComposableIndexTemplate.builder()
+            .indexPatterns((List<String>) a[0])
+            .template((Template) a[1])
+            .componentTemplates((List<String>) a[2])
+            .priority((Long) a[3])
+            .version((Long) a[4])
+            .metadata((Map<String, Object>) a[5])
+            .dataStreamTemplate((DataStreamTemplate) a[6])
+            .allowAutoCreate((Boolean) a[7])
+            .ignoreMissingComponentTemplates((List<String>) a[8])
+            .deprecated((Boolean) a[9])
+            .createdDate((Long) a[10])
+            .modifiedDate((Long) a[11])
+            .build()
     );
 
     static {
@@ -95,6 +103,8 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         PARSER.declareBoolean(ConstructingObjectParser.optionalConstructorArg(), ALLOW_AUTO_CREATE);
         PARSER.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), IGNORE_MISSING_COMPONENT_TEMPLATES);
         PARSER.declareBoolean(ConstructingObjectParser.optionalConstructorArg(), DEPRECATED);
+        PARSER.declareLong(ConstructingObjectParser.optionalConstructorArg(), CREATED_DATE_MILLIS);
+        PARSER.declareLong(ConstructingObjectParser.optionalConstructorArg(), MODIFIED_DATE_MILLIS);
     }
 
     private final List<String> indexPatterns;
@@ -116,6 +126,10 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
     private final List<String> ignoreMissingComponentTemplates;
     @Nullable
     private final Boolean deprecated;
+    @Nullable
+    private final Long createdDateMillis;
+    @Nullable
+    private final Long modifiedDateMillis;
 
     static Diff<ComposableIndexTemplate> readITV2DiffFrom(StreamInput in) throws IOException {
         return SimpleDiffable.readDiffFrom(ComposableIndexTemplate::new, in);
@@ -129,28 +143,19 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         return new Builder();
     }
 
-    private ComposableIndexTemplate(
-        List<String> indexPatterns,
-        @Nullable Template template,
-        @Nullable List<String> componentTemplates,
-        @Nullable Long priority,
-        @Nullable Long version,
-        @Nullable Map<String, Object> metadata,
-        @Nullable DataStreamTemplate dataStreamTemplate,
-        @Nullable Boolean allowAutoCreate,
-        @Nullable List<String> ignoreMissingComponentTemplates,
-        @Nullable Boolean deprecated
-    ) {
-        this.indexPatterns = indexPatterns;
-        this.template = template;
-        this.componentTemplates = componentTemplates == null ? List.of() : componentTemplates;
-        this.priority = priority;
-        this.version = version;
-        this.metadata = metadata;
-        this.dataStreamTemplate = dataStreamTemplate;
-        this.allowAutoCreate = allowAutoCreate;
-        this.ignoreMissingComponentTemplates = ignoreMissingComponentTemplates;
-        this.deprecated = deprecated;
+    private ComposableIndexTemplate(final Builder b) {
+        this.indexPatterns = b.indexPatterns;
+        this.template = b.template;
+        this.componentTemplates = b.componentTemplates == null ? List.of() : b.componentTemplates;
+        this.priority = b.priority;
+        this.version = b.version;
+        this.metadata = b.metadata;
+        this.dataStreamTemplate = b.dataStreamTemplate;
+        this.allowAutoCreate = b.allowAutoCreate;
+        this.ignoreMissingComponentTemplates = b.ignoreMissingComponentTemplates;
+        this.deprecated = b.deprecated;
+        this.createdDateMillis = b.createdDateMillis;
+        this.modifiedDateMillis = b.modifiedDateMillis;
     }
 
     public ComposableIndexTemplate(StreamInput in) throws IOException {
@@ -176,6 +181,13 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         } else {
             this.deprecated = null;
         }
+        if (in.getTransportVersion().onOrAfter(TransportVersions.INDEX_TEMPLATE_TRACKING_INFO)) {
+            this.createdDateMillis = in.readOptionalLong();
+            this.modifiedDateMillis = in.readOptionalLong();
+        } else {
+            this.createdDateMillis = null;
+            this.modifiedDateMillis = null;
+        }
     }
 
     public List<String> indexPatterns() {
@@ -257,6 +269,14 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         return Boolean.TRUE.equals(deprecated);
     }
 
+    public Optional<Long> createdDateMillis() {
+        return Optional.ofNullable(createdDateMillis);
+    }
+
+    public Optional<Long> modifiedDateMillis() {
+        return Optional.ofNullable(modifiedDateMillis);
+    }
+
     @Override
     public void writeTo(StreamOutput out) throws IOException {
         out.writeStringCollection(this.indexPatterns);
@@ -278,6 +298,10 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         if (out.getTransportVersion().onOrAfter(TransportVersions.V_8_12_0)) {
             out.writeOptionalBoolean(deprecated);
         }
+        if (out.getTransportVersion().onOrAfter(TransportVersions.INDEX_TEMPLATE_TRACKING_INFO)) {
+            out.writeOptionalLong(createdDateMillis);
+            out.writeOptionalLong(modifiedDateMillis);
+        }
     }
 
     @Override
@@ -320,6 +344,20 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         if (this.deprecated != null) {
             builder.field(DEPRECATED.getPreferredName(), deprecated);
         }
+        if (this.createdDateMillis != null) {
+            builder.timestampFieldsFromUnixEpochMillis(
+                CREATED_DATE_MILLIS.getPreferredName(),
+                CREATED_DATE.getPreferredName(),
+                this.createdDateMillis
+            );
+        }
+        if (this.modifiedDateMillis != null) {
+            builder.timestampFieldsFromUnixEpochMillis(
+                MODIFIED_DATE_MILLIS.getPreferredName(),
+                MODIFIED_DATE.getPreferredName(),
+                this.modifiedDateMillis
+            );
+        }
         builder.endObject();
         return builder;
     }
@@ -422,7 +460,9 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
             this.dataStreamTemplate,
             this.allowAutoCreate,
             this.ignoreMissingComponentTemplates,
-            this.deprecated
+            this.deprecated,
+            this.createdDateMillis,
+            this.modifiedDateMillis
         );
     }
 
@@ -444,7 +484,9 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
             && Objects.equals(this.dataStreamTemplate, other.dataStreamTemplate)
             && Objects.equals(this.allowAutoCreate, other.allowAutoCreate)
             && Objects.equals(this.ignoreMissingComponentTemplates, other.ignoreMissingComponentTemplates)
-            && Objects.equals(deprecated, other.deprecated);
+            && Objects.equals(deprecated, other.deprecated)
+            && Objects.equals(createdDateMillis, other.createdDateMillis)
+            && Objects.equals(modifiedDateMillis, other.modifiedDateMillis);
     }
 
     static boolean componentTemplatesEquals(List<String> c1, List<String> c2) {
@@ -598,6 +640,8 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
         private Boolean allowAutoCreate;
         private List<String> ignoreMissingComponentTemplates;
         private Boolean deprecated;
+        private Long createdDateMillis;
+        private Long modifiedDateMillis;
 
         /**
          * @deprecated use {@link ComposableIndexTemplate#builder()}
@@ -616,6 +660,8 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
             this.allowAutoCreate = template.allowAutoCreate;
             this.ignoreMissingComponentTemplates = template.ignoreMissingComponentTemplates;
             this.deprecated = template.deprecated;
+            this.createdDateMillis = template.createdDateMillis;
+            this.modifiedDateMillis = template.modifiedDateMillis;
         }
 
         public Builder indexPatterns(List<String> indexPatterns) {
@@ -673,19 +719,18 @@ public class ComposableIndexTemplate implements SimpleDiffable<ComposableIndexTe
             return this;
         }
 
+        public Builder createdDate(@Nullable Long createdDate) {
+            this.createdDateMillis = createdDate;
+            return this;
+        }
+
+        public Builder modifiedDate(@Nullable Long modifiedDate) {
+            this.modifiedDateMillis = modifiedDate;
+            return this;
+        }
+
         public ComposableIndexTemplate build() {
-            return new ComposableIndexTemplate(
-                this.indexPatterns,
-                this.template,
-                this.componentTemplates,
-                this.priority,
-                this.version,
-                this.metadata,
-                this.dataStreamTemplate,
-                this.allowAutoCreate,
-                this.ignoreMissingComponentTemplates,
-                this.deprecated
-            );
+            return new ComposableIndexTemplate(this);
         }
     }
 }

+ 25 - 10
server/src/main/java/org/elasticsearch/cluster/metadata/MetadataIndexTemplateService.java

@@ -605,6 +605,12 @@ public class MetadataIndexTemplateService {
     }
 
     public static void validateV2TemplateRequest(ProjectMetadata metadata, String name, ComposableIndexTemplate template) {
+        if (template.createdDateMillis().isPresent()) {
+            throw new InvalidIndexTemplateException(name, "provided a template property which is managed by the system: created_date");
+        }
+        if (template.modifiedDateMillis().isPresent()) {
+            throw new InvalidIndexTemplateException(name, "provided a template property which is managed by the system: modified_date");
+        }
         if (template.indexPatterns().stream().anyMatch(Regex::isMatchAllPattern)) {
             Settings mergedSettings = resolveSettings(template, metadata.componentTemplates());
             if (IndexMetadata.INDEX_HIDDEN_SETTING.exists(mergedSettings)) {
@@ -685,11 +691,9 @@ public class MetadataIndexTemplateService {
             HeaderWarning.addWarning(warning);
         }
 
-        final ComposableIndexTemplate finalIndexTemplate;
-        Template innerTemplate = template.template();
-        if (innerTemplate == null) {
-            finalIndexTemplate = template;
-        } else {
+        final ComposableIndexTemplate.Builder finalIndexTemplateBuilder = template.toBuilder();
+        final Template innerTemplate = template.template();
+        if (innerTemplate != null) {
             // We may need to normalize index settings, so do that also
             Settings finalSettings = innerTemplate.settings();
             if (finalSettings != null) {
@@ -697,14 +701,25 @@ public class MetadataIndexTemplateService {
             }
             // If an inner template was specified, its mappings may need to be
             // adjusted (to add _doc) and it should be validated
-            CompressedXContent mappings = innerTemplate.mappings();
-            CompressedXContent wrappedMappings = wrapMappingsIfNecessary(mappings, xContentRegistry);
+            final CompressedXContent mappings = innerTemplate.mappings();
+            final CompressedXContent wrappedMappings = wrapMappingsIfNecessary(mappings, xContentRegistry);
             final Template finalTemplate = Template.builder(innerTemplate).settings(finalSettings).mappings(wrappedMappings).build();
-            finalIndexTemplate = template.toBuilder().template(finalTemplate).build();
+            finalIndexTemplateBuilder.template(finalTemplate);
         }
 
-        if (finalIndexTemplate.equals(existing)) {
-            return project;
+        final long now = instantSource.millis();
+        final ComposableIndexTemplate finalIndexTemplate;
+        if (existing == null) {
+            finalIndexTemplate = finalIndexTemplateBuilder.createdDate(now).modifiedDate(now).build();
+        } else {
+            final ComposableIndexTemplate templateToCompareToExisting = finalIndexTemplateBuilder.createdDate(
+                existing.createdDateMillis().orElse(null)
+            ).modifiedDate(existing.modifiedDateMillis().orElse(null)).build();
+
+            if (templateToCompareToExisting.equals(existing)) {
+                return project;
+            }
+            finalIndexTemplate = finalIndexTemplateBuilder.modifiedDate(now).build();
         }
 
         validateIndexTemplateV2(project, name, finalIndexTemplate);

+ 7 - 2
server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestPutComposableIndexTemplateAction.java

@@ -31,7 +31,12 @@ import static org.elasticsearch.rest.action.admin.indices.RestPutComponentTempla
 @ServerlessScope(Scope.PUBLIC)
 public class RestPutComposableIndexTemplateAction extends BaseRestHandler {
 
-    private static final Set<String> capabilities = Set.of(SUPPORTS_FAILURE_STORE, SUPPORTS_FAILURE_STORE_LIFECYCLE);
+    private static final String INDEX_TEMPLATE_TRACKING_INFO = "index_template_tracking_info";
+    private static final Set<String> CAPABILITIES = Set.of(
+        SUPPORTS_FAILURE_STORE,
+        SUPPORTS_FAILURE_STORE_LIFECYCLE,
+        INDEX_TEMPLATE_TRACKING_INFO
+    );
 
     @Override
     public List<Route> routes() {
@@ -61,6 +66,6 @@ public class RestPutComposableIndexTemplateAction extends BaseRestHandler {
 
     @Override
     public Set<String> supportedCapabilities() {
-        return capabilities;
+        return CAPABILITIES;
     }
 }

+ 9 - 0
server/src/test/java/org/elasticsearch/cluster/metadata/ComposableIndexTemplateTests.java

@@ -88,6 +88,13 @@ public class ComposableIndexTemplateTests extends SimpleDiffableSerializationTes
 
         List<String> indexPatterns = randomList(1, 4, () -> randomAlphaOfLength(4));
         List<String> ignoreMissingComponentTemplates = randomList(0, 4, () -> randomAlphaOfLength(4));
+        final Long createdDate = randomBoolean() ? null : randomNonNegativeLong();
+        final Long modifiedDate;
+        if (randomBoolean()) {
+            modifiedDate = createdDate == null ? randomNonNegativeLong() : randomLongBetween(createdDate, Long.MAX_VALUE);
+        } else {
+            modifiedDate = null;
+        }
         return ComposableIndexTemplate.builder()
             .indexPatterns(indexPatterns)
             .template(template)
@@ -99,6 +106,8 @@ public class ComposableIndexTemplateTests extends SimpleDiffableSerializationTes
             .allowAutoCreate(randomOptionalBoolean())
             .ignoreMissingComponentTemplates(ignoreMissingComponentTemplates)
             .deprecated(randomOptionalBoolean())
+            .createdDate(createdDate)
+            .modifiedDate(modifiedDate)
             .build();
     }
 

+ 141 - 39
server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexTemplateServiceTests.java

@@ -427,9 +427,9 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         ComponentTemplate componentTemplate = new ComponentTemplate(template, 1L, new HashMap<>());
         project = metadataIndexTemplateService.addComponentTemplate(project, false, "foo", componentTemplate);
 
-        ComponentTemplate foo = project.componentTemplates().get("foo");
-        ComponentTemplate expectedFoo = new ComponentTemplate(template, 1L, Map.of(), null, 0L, 0L);
-        assertThat(foo, equalTo(expectedFoo));
+        ComponentTemplate actualTemplateFoo = project.componentTemplates().get("foo");
+        ComponentTemplate expectedTemplateFoo = new ComponentTemplate(template, 1L, Map.of(), null, 0L, 0L);
+        assertThat(actualTemplateFoo, equalTo(expectedTemplateFoo));
 
         ProjectMetadata throwState = ProjectMetadata.builder(project).build();
         IllegalArgumentException e = expectThrows(
@@ -533,8 +533,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         ComposableIndexTemplate template = ComposableIndexTemplateTests.randomInstance();
         project = metadataIndexTemplateService.addIndexTemplateV2(project, false, "foo", template);
 
-        assertNotNull(project.templatesV2().get("foo"));
-        assertTemplatesEqual(project.templatesV2().get("foo"), template);
+        final ComposableIndexTemplate expectedTemplateFoo = template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedTemplateFoo, project.templatesV2().get("foo"));
 
         ComposableIndexTemplate newTemplate = randomValueOtherThanMany(
             t -> Objects.equals(template.priority(), t.priority()),
@@ -558,16 +558,16 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         ComposableIndexTemplate template = ComposableIndexTemplateTests.randomInstance();
         project = metadataIndexTemplateService.addIndexTemplateV2(project, false, "foo", template);
 
-        assertNotNull(project.templatesV2().get("foo"));
-        assertTemplatesEqual(project.templatesV2().get("foo"), template);
+        final ComposableIndexTemplate expectedTemplateFoo = template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedTemplateFoo, project.templatesV2().get("foo"));
 
         List<String> patterns = new ArrayList<>(template.indexPatterns());
         patterns.add("new-pattern");
         template = template.toBuilder().indexPatterns(patterns).build();
         project = metadataIndexTemplateService.addIndexTemplateV2(project, false, "foo", template);
 
-        assertNotNull(project.templatesV2().get("foo"));
-        assertTemplatesEqual(project.templatesV2().get("foo"), template);
+        final ComposableIndexTemplate updatedExpectedTemplateFoo = template.toBuilder().createdDate(0L).modifiedDate(2L).build();
+        assertTemplatesEqual(updatedExpectedTemplateFoo, project.templatesV2().get("foo"));
     }
 
     public void testRemoveIndexTemplateV2() throws Exception {
@@ -581,8 +581,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         assertThat(e.getMessage(), equalTo("index_template [foo] missing"));
 
         ProjectMetadata project = service.addIndexTemplateV2(initialProject, false, "foo", template);
-        assertNotNull(project.templatesV2().get("foo"));
-        assertTemplatesEqual(project.templatesV2().get("foo"), template);
+        final ComposableIndexTemplate expectedTemplateFoo = template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedTemplateFoo, project.templatesV2().get("foo"));
 
         ProjectMetadata updatedState = MetadataIndexTemplateService.innerRemoveIndexTemplateV2(project, "foo");
         assertNull(updatedState.templatesV2().get("foo"));
@@ -598,7 +598,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         ProjectMetadata project = metadataIndexTemplateService.addIndexTemplateV2(initialProject, false, "foo", template);
         assertThat(project.templatesV2().get("foo"), notNullValue());
 
-        assertTemplatesEqual(project.templatesV2().get("foo"), template);
+        final ComposableIndexTemplate expectedTemplateFoo = template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedTemplateFoo, project.templatesV2().get("foo"));
 
         Exception e = expectThrows(
             IndexTemplateMissingException.class,
@@ -620,12 +621,12 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         ProjectMetadata project = service.addIndexTemplateV2(initialProject, false, "foo", fooTemplate);
         project = service.addIndexTemplateV2(project, false, "bar", barTemplate);
         project = service.addIndexTemplateV2(project, false, "baz", bazTemplate);
-        assertNotNull(project.templatesV2().get("foo"));
-        assertNotNull(project.templatesV2().get("bar"));
-        assertNotNull(project.templatesV2().get("baz"));
-        assertTemplatesEqual(project.templatesV2().get("foo"), fooTemplate);
-        assertTemplatesEqual(project.templatesV2().get("bar"), barTemplate);
-        assertTemplatesEqual(project.templatesV2().get("baz"), bazTemplate);
+        final ComposableIndexTemplate expectedTemplateFoo = fooTemplate.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        final ComposableIndexTemplate expectedTemplateBar = barTemplate.toBuilder().createdDate(2L).modifiedDate(2L).build();
+        final ComposableIndexTemplate expectedTemplateBaz = bazTemplate.toBuilder().createdDate(4L).modifiedDate(4L).build();
+        assertTemplatesEqual(expectedTemplateFoo, project.templatesV2().get("foo"));
+        assertTemplatesEqual(expectedTemplateBar, project.templatesV2().get("bar"));
+        assertTemplatesEqual(expectedTemplateBaz, project.templatesV2().get("baz"));
 
         ProjectMetadata updatedState = MetadataIndexTemplateService.innerRemoveIndexTemplateV2(project, "foo", "baz");
         assertNull(updatedState.templatesV2().get("foo"));
@@ -653,12 +654,13 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         );
         assertThat(e.getMessage(), equalTo("index_template [b*,k*,*] missing"));
 
-        assertNotNull(project.templatesV2().get("foo"));
-        assertNotNull(project.templatesV2().get("bar"));
-        assertNotNull(project.templatesV2().get("baz"));
-        assertTemplatesEqual(project.templatesV2().get("foo"), fooTemplate);
-        assertTemplatesEqual(project.templatesV2().get("bar"), barTemplate);
-        assertTemplatesEqual(project.templatesV2().get("baz"), bazTemplate);
+        final ComposableIndexTemplate expectedTemplateFoo = fooTemplate.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        final ComposableIndexTemplate expectedTemplateBar = barTemplate.toBuilder().createdDate(2L).modifiedDate(2L).build();
+        final ComposableIndexTemplate expectedTemplateBaz = bazTemplate.toBuilder().createdDate(4L).modifiedDate(4L).build();
+
+        assertTemplatesEqual(expectedTemplateFoo, project.templatesV2().get("foo"));
+        assertTemplatesEqual(expectedTemplateBar, project.templatesV2().get("bar"));
+        assertTemplatesEqual(expectedTemplateBaz, project.templatesV2().get("baz"));
     }
 
     /**
@@ -681,8 +683,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 + "take precedence during new index creation"
         );
 
-        assertNotNull(project.templatesV2().get("v2-template"));
-        assertTemplatesEqual(project.templatesV2().get("v2-template"), v2Template);
+        final ComposableIndexTemplate expectedV2Template = v2Template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedV2Template, project.templatesV2().get("v2-template"));
     }
 
     public void testPutGlobalV2TemplateWhichResolvesIndexHiddenSetting() throws Exception {
@@ -832,8 +834,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 + "take precedence during new index creation"
         );
 
-        assertNotNull(project.templatesV2().get("v2-template"));
-        assertTemplatesEqual(project.templatesV2().get("v2-template"), v2Template);
+        final ComposableIndexTemplate expectedV2Template = v2Template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedV2Template, project.templatesV2().get("v2-template"));
 
         // Now try to update the existing v1-template
 
@@ -872,8 +874,8 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
                 + "take precedence during new index creation"
         );
 
-        assertNotNull(project.templatesV2().get("v2-template"));
-        assertTemplatesEqual(project.templatesV2().get("v2-template"), v2Template);
+        final ComposableIndexTemplate expectedV2 = v2Template.toBuilder().createdDate(0L).modifiedDate(0L).build();
+        assertTemplatesEqual(expectedV2, project.templatesV2().get("v2-template"));
 
         // Now try to update the existing v1-template
 
@@ -1932,15 +1934,36 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
 
         ProjectMetadata result = innerRemoveComponentTemplate(projectMetadata, "foo");
         // created_date and modified_date come from monotonically increasing clock
-        ComponentTemplate expectedBar = new ComponentTemplate(bar.template(), bar.version(), bar.metadata(), bar.deprecated(), 1L, 1L);
-        ComponentTemplate expectedBaz = new ComponentTemplate(baz.template(), baz.version(), baz.metadata(), baz.deprecated(), 2L, 2L);
+        ComponentTemplate expectedTemplateBar = new ComponentTemplate(
+            bar.template(),
+            bar.version(),
+            bar.metadata(),
+            bar.deprecated(),
+            1L,
+            1L
+        );
+        ComponentTemplate expectedTemplateBaz = new ComponentTemplate(
+            baz.template(),
+            baz.version(),
+            baz.metadata(),
+            baz.deprecated(),
+            2L,
+            2L
+        );
         assertThat(result.componentTemplates().get("foo"), nullValue());
-        assertThat(result.componentTemplates().get("bar"), equalTo(expectedBar));
-        assertThat(result.componentTemplates().get("baz"), equalTo(expectedBaz));
+        assertThat(result.componentTemplates().get("bar"), equalTo(expectedTemplateBar));
+        assertThat(result.componentTemplates().get("baz"), equalTo(expectedTemplateBaz));
 
         result = innerRemoveComponentTemplate(projectMetadata, "bar", "baz");
-        ComponentTemplate expectedFoo = new ComponentTemplate(foo.template(), foo.version(), foo.metadata(), foo.deprecated(), 0L, 0L);
-        assertThat(result.componentTemplates().get("foo"), equalTo(expectedFoo));
+        ComponentTemplate expectedTemplateFoo = new ComponentTemplate(
+            foo.template(),
+            foo.version(),
+            foo.metadata(),
+            foo.deprecated(),
+            0L,
+            0L
+        );
+        assertThat(result.componentTemplates().get("foo"), equalTo(expectedTemplateFoo));
         assertThat(result.componentTemplates().get("bar"), nullValue());
         assertThat(result.componentTemplates().get("baz"), nullValue());
 
@@ -1954,7 +1977,7 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
 
         result = innerRemoveComponentTemplate(projectMetadata, "b*");
         assertThat(result.componentTemplates().size(), equalTo(1));
-        assertThat(result.componentTemplates().get("foo"), equalTo(expectedFoo));
+        assertThat(result.componentTemplates().get("foo"), equalTo(expectedTemplateFoo));
 
         e = expectThrows(ResourceNotFoundException.class, () -> innerRemoveComponentTemplate(projectMetadata, "foo", "b*"));
         assertThat(e.getMessage(), equalTo("b*"));
@@ -2826,6 +2849,85 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         assertThat(newTemplate.modifiedDateMillis().orElseThrow(), is(1L));
     }
 
+    public void testIndexTemplateNoOpDoesNotChangeTracking() throws Exception {
+        final String name = "test-template";
+        final ProjectId projectId = randomProjectIdOrDefault();
+        final Template template = new Template(Settings.builder().put("index.number_of_shards", 1).build(), null, null);
+        final MetadataIndexTemplateService service = getMetadataIndexTemplateService();
+
+        // create template
+        final ComposableIndexTemplate indexTemplate = ComposableIndexTemplate.builder()
+            .indexPatterns(List.of("test-*"))
+            .template(template)
+            .priority(1L)
+            .build();
+        final ProjectMetadata initialMetadata = ProjectMetadata.builder(projectId).build();
+        final ProjectMetadata updatedMetadata = service.addIndexTemplateV2(initialMetadata, false, name, indexTemplate);
+        final ComposableIndexTemplate addedTemplate = updatedMetadata.templatesV2().get(name);
+        assertThat(addedTemplate.createdDateMillis().orElseThrow(), is(0L));
+        assertThat(addedTemplate.modifiedDateMillis().orElseThrow(), is(0L));
+
+        // update template which should result in NOP
+        final ProjectMetadata sameMetadata = service.addIndexTemplateV2(updatedMetadata, false, name, indexTemplate);
+        assertThat(sameMetadata, sameInstance(updatedMetadata));
+        final ComposableIndexTemplate unchangedTemplate = sameMetadata.templatesV2().get(name);
+        assertThat(unchangedTemplate.createdDateMillis().orElseThrow(), is(0L));
+        assertThat(unchangedTemplate.modifiedDateMillis().orElseThrow(), is(0L));
+    }
+
+    public void testIndexTemplateUpdateWithoutExistingTracking() throws Exception {
+        final String name = "test-template";
+        final ProjectId projectId = randomProjectIdOrDefault();
+        final MetadataIndexTemplateService service = getMetadataIndexTemplateService();
+        final ComposableIndexTemplate initialTemplate = ComposableIndexTemplate.builder()
+            .indexPatterns(List.of("test-*"))
+            .template(new Template(Settings.builder().put("index.number_of_shards", 1).build(), null, null))
+            .priority(1L)
+            .build();
+        final ProjectMetadata initialMetadata = ProjectMetadata.builder(projectId).put(name, initialTemplate).build();
+
+        final ComposableIndexTemplate updateTemplate = ComposableIndexTemplate.builder()
+            .indexPatterns(List.of("test-*"))
+            .template(new Template(Settings.builder().put("index.number_of_shards", 2).build(), null, null))
+            .priority(1L)
+            .build();
+        final ProjectMetadata afterCreateMetadata = service.addIndexTemplateV2(initialMetadata, false, name, updateTemplate);
+
+        final ComposableIndexTemplate newTemplate = afterCreateMetadata.templatesV2().get(name);
+        assertTrue(newTemplate.createdDateMillis().isEmpty());
+        assertThat(newTemplate.modifiedDateMillis().orElseThrow(), is(0L));
+    }
+
+    public void testIndexTemplateUpdateChangesModifiedDate() throws Exception {
+        final String name = "test-template";
+        final ProjectId projectId = randomProjectIdOrDefault();
+        final MetadataIndexTemplateService service = getMetadataIndexTemplateService();
+        final ProjectMetadata initialMetadata = ProjectMetadata.builder(projectId).build();
+        final Template template = new Template(Settings.builder().put("index.number_of_shards", 1).build(), null, null);
+
+        // create template
+        final ComposableIndexTemplate indexTemplate = ComposableIndexTemplate.builder()
+            .indexPatterns(List.of("test-*"))
+            .template(template)
+            .priority(1L)
+            .build();
+        final ProjectMetadata afterCreateMetadata = service.addIndexTemplateV2(initialMetadata, false, name, indexTemplate);
+        final ComposableIndexTemplate addedTemplate = afterCreateMetadata.templatesV2().get(name);
+        assertThat(addedTemplate.createdDateMillis().orElseThrow(), is(0L));
+        assertThat(addedTemplate.modifiedDateMillis().orElseThrow(), is(0L));
+
+        // update template
+        final ComposableIndexTemplate updatedIndexTemplate = ComposableIndexTemplate.builder()
+            .indexPatterns(List.of("test-*"))
+            .template(template)
+            .priority(2L)
+            .build();
+        final ProjectMetadata afterUpdateMetadata = service.addIndexTemplateV2(afterCreateMetadata, false, name, updatedIndexTemplate);
+        final ComposableIndexTemplate newTemplate = afterUpdateMetadata.templatesV2().get(name);
+        assertThat(newTemplate.createdDateMillis().orElseThrow(), is(0L));
+        assertThat(newTemplate.modifiedDateMillis().orElseThrow(), is(2L));
+    }
+
     private static List<Throwable> putTemplate(NamedXContentRegistry xContentRegistry, PutRequest request) {
         ThreadPool testThreadPool = mock(ThreadPool.class);
         when(testThreadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY));
@@ -2923,7 +3025,7 @@ public class MetadataIndexTemplateServiceTests extends ESSingleNodeTestCase {
         );
     }
 
-    public static void assertTemplatesEqual(ComposableIndexTemplate actual, ComposableIndexTemplate expected) {
-        assertEquals(actual, expected);
+    public static void assertTemplatesEqual(ComposableIndexTemplate expected, ComposableIndexTemplate actual) {
+        assertThat(actual, equalTo(expected));
     }
 }

+ 2 - 0
x-pack/plugin/ilm/src/main/java/org/elasticsearch/xpack/cluster/metadata/MetadataMigrateToDataTiersRoutingService.java

@@ -760,6 +760,8 @@ public final class MetadataMigrateToDataTiersRoutingService {
                     migratedComposableTemplateBuilder.ignoreMissingComponentTemplates(
                         composableTemplate.getIgnoreMissingComponentTemplates()
                     );
+                    migratedComposableTemplateBuilder.createdDate(composableTemplate.createdDateMillis().orElse(null));
+                    migratedComposableTemplateBuilder.modifiedDate(composableTemplate.modifiedDateMillis().orElse(null));
 
                     projectMetadataBuilder.put(templateEntry.getKey(), migratedComposableTemplateBuilder.build());
                     migratedComposableTemplates.add(templateEntry.getKey());