Bladeren bron

Reject creating ILM policies with phase timings are not >= previous phase (#70089)

It can be confusing to configure policies with phase timings that get smaller, because phase timings
are absolute. To make things a little clearer, this commit now rejects policies where a configured
min_age is less than a previous phase's min_age.

This validation is added only to the PutLifecycleAction.Request instead of the
TimeseriesLifecycleType class because we cannot do this validation every time a lifecycle is
created or else we will block cluster state from being recoverable for existing clusters that may
have invalid policies.

Resolves #70032
Lee Hinman 4 jaren geleden
bovenliggende
commit
5df763fc66

+ 5 - 2
client/rest-high-level/src/test/java/org/elasticsearch/client/ilm/LifecyclePolicyTests.java

@@ -194,7 +194,7 @@ public class LifecyclePolicyTests extends AbstractXContentTestCase<LifecyclePoli
     }
 
     public static LifecyclePolicy createRandomPolicy(String lifecycleName) {
-        List<String> phaseNames = randomSubsetOf(Arrays.asList("hot", "warm", "cold", "delete"));
+        List<String> phaseNames = Arrays.asList("hot", "warm", "cold", "delete");
         Map<String, Phase> phases = new HashMap<>(phaseNames.size());
         Function<String, Set<String>> validActions = (phase) ->  {
             switch (phase) {
@@ -247,8 +247,11 @@ public class LifecyclePolicyTests extends AbstractXContentTestCase<LifecyclePoli
                 default:
                     throw new IllegalArgumentException("invalid action [" + action + "]");
             }};
+        TimeValue prev = null;
         for (String phase : phaseNames) {
-            TimeValue after = TimeValue.parseTimeValue(randomTimeValue(0, 1000000000, "s", "m", "h", "d"), "test_after");
+            TimeValue after = prev == null ? TimeValue.parseTimeValue(randomTimeValue(0, 10000, "s", "m", "h", "d"), "test_after") :
+                TimeValue.timeValueSeconds(prev.seconds() + randomIntBetween(60, 600));
+            prev = after;
             Map<String, LifecycleAction> actions = new HashMap<>();
             List<String> actionNames;
             if (allowEmptyActions.apply(phase)) {

+ 5 - 3
docs/reference/ilm/ilm-index-lifecycle.asciidoc

@@ -35,9 +35,11 @@ update.
 === Phase transitions
 
 {ilm-init} moves indices through the lifecycle according to their age. 
-To control the timing of these transitions, you set a _minimum age_ for each phase. 
-For an index to move to the next phase, all actions in the current phase must be complete and 
-the index must be older than the minimum age of the next phase. 
+To control the timing of these transitions, you set a _minimum age_ for each phase. For an index to
+move to the next phase, all actions in the current phase must be complete and the index must be
+older than the minimum age of the next phase. Configured minimum ages must increase between
+subsequent phases, for example, a "warm" phase with a minimum age of 10 days can only be followed by
+a "cold" phase with a minimum age either unset, or >= 10 days.
 
 The minimum age defaults to zero, which causes {ilm-init} to move indices to the next phase
 as soon as all actions in the current phase complete. 

+ 58 - 8
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ilm/TimeseriesLifecycleType.java

@@ -8,6 +8,7 @@ package org.elasticsearch.xpack.core.ilm;
 
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.unit.TimeValue;
 import org.elasticsearch.common.util.set.Sets;
 import org.elasticsearch.rollup.RollupV2;
 
@@ -17,6 +18,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -43,7 +45,7 @@ public class TimeseriesLifecycleType implements LifecycleType {
     static final String COLD_PHASE = "cold";
     static final String FROZEN_PHASE = "frozen";
     static final String DELETE_PHASE = "delete";
-    static final List<String> VALID_PHASES = Arrays.asList(HOT_PHASE, WARM_PHASE, COLD_PHASE, FROZEN_PHASE, DELETE_PHASE);
+    static final List<String> ORDERED_VALID_PHASES = Arrays.asList(HOT_PHASE, WARM_PHASE, COLD_PHASE, FROZEN_PHASE, DELETE_PHASE);
     static final List<String> ORDERED_VALID_HOT_ACTIONS;
     static final List<String> ORDERED_VALID_WARM_ACTIONS = Arrays.asList(SetPriorityAction.NAME, UnfollowAction.NAME, ReadOnlyAction.NAME,
         AllocateAction.NAME, MigrateAction.NAME, ShrinkAction.NAME, ForceMergeAction.NAME);
@@ -100,8 +102,8 @@ public class TimeseriesLifecycleType implements LifecycleType {
     }
 
     public List<Phase> getOrderedPhases(Map<String, Phase> phases) {
-        List<Phase> orderedPhases = new ArrayList<>(VALID_PHASES.size());
-        for (String phaseName : VALID_PHASES) {
+        List<Phase> orderedPhases = new ArrayList<>(ORDERED_VALID_PHASES.size());
+        for (String phaseName : ORDERED_VALID_PHASES) {
             Phase phase = phases.get(phaseName);
             if (phase != null) {
                 Map<String, LifecycleAction> actions = phase.getActions();
@@ -148,13 +150,13 @@ public class TimeseriesLifecycleType implements LifecycleType {
 
     @Override
     public String getNextPhaseName(String currentPhaseName, Map<String, Phase> phases) {
-        int index = VALID_PHASES.indexOf(currentPhaseName);
+        int index = ORDERED_VALID_PHASES.indexOf(currentPhaseName);
         if (index < 0 && "new".equals(currentPhaseName) == false) {
             throw new IllegalArgumentException("[" + currentPhaseName + "] is not a valid phase for lifecycle type [" + TYPE + "]");
         } else {
             // Find the next phase after `index` that exists in `phases` and return it
-            while (++index < VALID_PHASES.size()) {
-                String phaseName = VALID_PHASES.get(index);
+            while (++index < ORDERED_VALID_PHASES.size()) {
+                String phaseName = ORDERED_VALID_PHASES.get(index);
                 if (phases.containsKey(phaseName)) {
                     return phaseName;
                 }
@@ -170,13 +172,13 @@ public class TimeseriesLifecycleType implements LifecycleType {
         if ("new".equals(currentPhaseName)) {
             return null;
         }
-        int index = VALID_PHASES.indexOf(currentPhaseName);
+        int index = ORDERED_VALID_PHASES.indexOf(currentPhaseName);
         if (index < 0) {
             throw new IllegalArgumentException("[" + currentPhaseName + "] is not a valid phase for lifecycle type [" + TYPE + "]");
         } else {
             // Find the previous phase before `index` that exists in `phases` and return it
             while (--index >= 0) {
-                String phaseName = VALID_PHASES.get(index);
+                String phaseName = ORDERED_VALID_PHASES.get(index);
                 if (phases.containsKey(phaseName)) {
                     return phaseName;
                 }
@@ -363,6 +365,54 @@ public class TimeseriesLifecycleType implements LifecycleType {
         }
     }
 
+    /**
+     * Validates that phases don't configure a min_age that is smaller than a previous phase (which can be confusing to users)
+     */
+    public static String validateMonotonicallyIncreasingPhaseTimings(Collection<Phase> phases) {
+        List<String> errors = new ArrayList<>();
+        Set<String> invalidPhases = new HashSet<>();
+
+        // Loop through all phases in order, for each phase with a min_age
+        // configured, look at all the future phases to see if their ages are
+        // >= the configured age. A min_age of 0 means that the age was not
+        // configured, so we don't validate it.
+        for (int i = 0; i < ORDERED_VALID_PHASES.size(); i++) {
+            String phaseName = ORDERED_VALID_PHASES.get(i);
+            // Check if this phase is present with a configured min_age
+            Optional<Phase> maybePhase = phases.stream()
+                .filter(p -> phaseName.equals(p.getName()))
+                .filter(p -> p.getMinimumAge() != null && p.getMinimumAge().equals(TimeValue.ZERO) == false)
+                .findFirst();
+
+            if (maybePhase.isPresent()) {
+                Phase phase = maybePhase.get();
+                // We only consider a phase bad once, otherwise we can duplicate
+                // errors, so we keep track of the invalid phases we've seen and
+                // ignore them if they come around again.
+                if (invalidPhases.contains(phase.getName())) {
+                    continue;
+                }
+                TimeValue phaseMinAge = phase.getMinimumAge();
+                Set<String> followingPhases = new HashSet<>(ORDERED_VALID_PHASES.subList(i + 1, ORDERED_VALID_PHASES.size()));
+                Set<Phase> phasesWithBadAges = phases.stream()
+                    .filter(p -> followingPhases.contains(p.getName()))
+                    .filter(p -> p.getMinimumAge() != null && p.getMinimumAge().equals(TimeValue.ZERO) == false)
+                    .filter(p -> p.getMinimumAge().compareTo(phaseMinAge) < 0)
+                    .collect(Collectors.toSet());
+                if (phasesWithBadAges.size() > 0) {
+                    phasesWithBadAges.forEach(p -> invalidPhases.add(p.getName()));
+                    errors.add("phases [" + phasesWithBadAges.stream().map(Phase::getName).collect(Collectors.joining(",")) +
+                        "] configure a [min_age] value less than the [min_age] of [" + phase.getMinimumAge() +
+                        "] for the [" + phaseName + "] phase, configuration: " +
+                        phasesWithBadAges.stream().collect(Collectors.toMap(Phase::getName, Phase::getMinimumAge)));
+                }
+            }
+        }
+
+        // If we found any invalid phase timings, concatenate their messages and return the message
+        return Strings.collectionToCommaDelimitedString(errors);
+    }
+
     private static boolean definesAllocationRules(AllocateAction action) {
         return action.getRequire().isEmpty() == false || action.getInclude().isEmpty() == false || action.getExclude().isEmpty() == false;
     }

+ 8 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ilm/action/PutLifecycleAction.java

@@ -19,6 +19,7 @@ import org.elasticsearch.common.xcontent.ToXContentObject;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.xpack.core.ilm.LifecyclePolicy;
+import org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType;
 
 import java.io.IOException;
 import java.util.Objects;
@@ -60,7 +61,13 @@ public class PutLifecycleAction extends ActionType<AcknowledgedResponse> {
 
         @Override
         public ActionRequestValidationException validate() {
-            return null;
+            ActionRequestValidationException err = null;
+            String phaseTimingErr = TimeseriesLifecycleType.validateMonotonicallyIncreasingPhaseTimings(this.policy.getPhases().values());
+            if (Strings.hasText(phaseTimingErr)) {
+                err = new ActionRequestValidationException();
+                err.addValidationError(phaseTimingErr);
+            }
+            return err;
         }
 
         public static Request parseRequest(String name, XContentParser parser) {

+ 15 - 8
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ilm/LifecyclePolicyTests.java

@@ -102,12 +102,15 @@ public class LifecyclePolicyTests extends AbstractSerializingTestCase<LifecycleP
      * that the resulting policy has all valid phases and all valid actions.
      */
     public static LifecyclePolicy randomTimeseriesLifecyclePolicyWithAllPhases(@Nullable String lifecycleName) {
-        List<String> phaseNames = TimeseriesLifecycleType.VALID_PHASES;
+        List<String> phaseNames = TimeseriesLifecycleType.ORDERED_VALID_PHASES;
         Map<String, Phase> phases = new HashMap<>(phaseNames.size());
         Function<String, Set<String>> validActions = getPhaseToValidActions();
         Function<String, LifecycleAction> randomAction = getNameToActionFunction();
+        TimeValue prev = null;
         for (String phase : phaseNames) {
-            TimeValue after = TimeValue.parseTimeValue(randomTimeValue(0, 1000000000, "s", "m", "h", "d"), "test_after");
+            TimeValue after = prev == null ? TimeValue.parseTimeValue(randomTimeValue(0, 100000, "s", "m", "h", "d"), "test_after") :
+                TimeValue.timeValueSeconds(prev.seconds() + randomIntBetween(60, 600));
+            prev = after;
             Map<String, LifecycleAction> actions = new HashMap<>();
             Set<String> actionNames = validActions.apply(phase);
             if (phase.equals(TimeseriesLifecycleType.HOT_PHASE) == false) {
@@ -125,7 +128,7 @@ public class LifecyclePolicyTests extends AbstractSerializingTestCase<LifecycleP
 
     public static LifecyclePolicy randomTimeseriesLifecyclePolicy(@Nullable String lifecycleName) {
         List<String> phaseNames = randomSubsetOf(
-            between(0, TimeseriesLifecycleType.VALID_PHASES.size() - 1), TimeseriesLifecycleType.VALID_PHASES);
+            between(0, TimeseriesLifecycleType.ORDERED_VALID_PHASES.size() - 1), TimeseriesLifecycleType.ORDERED_VALID_PHASES);
         Map<String, Phase> phases = new HashMap<>(phaseNames.size());
         Function<String, Set<String>> validActions = getPhaseToValidActions();
         Function<String, LifecycleAction> randomAction = getNameToActionFunction();
@@ -139,14 +142,17 @@ public class LifecyclePolicyTests extends AbstractSerializingTestCase<LifecycleP
         boolean coldPhaseContainsSearchableSnap = false;
         // let's order the phases so we can reason about actions in a previous phase in order to generate a random *valid* policy
         List<String> orderedPhases = new ArrayList<>(phaseNames.size());
-        for (String validPhase : TimeseriesLifecycleType.VALID_PHASES) {
+        for (String validPhase : TimeseriesLifecycleType.ORDERED_VALID_PHASES) {
             if (phaseNames.contains(validPhase)) {
                 orderedPhases.add(validPhase);
             }
         }
 
+        TimeValue prev = null;
         for (String phase : orderedPhases) {
-            TimeValue after = TimeValue.parseTimeValue(randomTimeValue(0, 1000000000, "s", "m", "h", "d"), "test_after");
+            TimeValue after = prev == null ? TimeValue.parseTimeValue(randomTimeValue(0, 100000, "s", "m", "h", "d"), "test_after") :
+                TimeValue.timeValueSeconds(prev.seconds() + randomIntBetween(60, 600));
+            prev = after;
             Map<String, LifecycleAction> actions = new HashMap<>();
             List<String> actionNames = randomSubsetOf(validActions.apply(phase));
 
@@ -242,7 +248,7 @@ public class LifecyclePolicyTests extends AbstractSerializingTestCase<LifecycleP
         int numberPhases = randomInt(5);
         Map<String, Phase> phases = new HashMap<>(numberPhases);
         for (int i = 0; i < numberPhases; i++) {
-            TimeValue after = TimeValue.parseTimeValue(randomTimeValue(0, 1000000000, "s", "m", "h", "d"), "test_after");
+            TimeValue after = TimeValue.parseTimeValue(randomTimeValue(0, 10000, "s", "m", "h", "d"), "test_after");
             Map<String, LifecycleAction> actions = new HashMap<>();
             if (randomBoolean()) {
                 MockAction action = new MockAction();
@@ -263,9 +269,10 @@ public class LifecyclePolicyTests extends AbstractSerializingTestCase<LifecycleP
                 name = name + randomAlphaOfLengthBetween(1, 5);
                 break;
             case 1:
-                String phaseName = randomValueOtherThanMany(phases::containsKey, () -> randomFrom(TimeseriesLifecycleType.VALID_PHASES));
+                String phaseName = randomValueOtherThanMany(phases::containsKey,
+                    () -> randomFrom(TimeseriesLifecycleType.ORDERED_VALID_PHASES));
                 phases = new LinkedHashMap<>(phases);
-                phases.put(phaseName, new Phase(phaseName, TimeValue.timeValueSeconds(randomIntBetween(1, 1000)), Collections.emptyMap()));
+                phases.put(phaseName, new Phase(phaseName, null, Collections.emptyMap()));
                 break;
             default:
                 throw new AssertionError("Illegal randomisation branch");

+ 1 - 1
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ilm/MigrateActionTests.java

@@ -51,7 +51,7 @@ public class MigrateActionTests extends AbstractActionTestCase<MigrateAction> {
     }
 
     public void testToSteps() {
-        String phase = randomValueOtherThan(DELETE_PHASE, () -> randomFrom(TimeseriesLifecycleType.VALID_PHASES));
+        String phase = randomValueOtherThan(DELETE_PHASE, () -> randomFrom(TimeseriesLifecycleType.ORDERED_VALID_PHASES));
         StepKey nextStepKey = new StepKey(randomAlphaOfLengthBetween(1, 10), randomAlphaOfLengthBetween(1, 10),
             randomAlphaOfLengthBetween(1, 10));
         {

+ 90 - 4
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ilm/TimeseriesLifecycleTypeTests.java

@@ -6,6 +6,7 @@
  */
 package org.elasticsearch.xpack.core.ilm;
 
+import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.unit.ByteSizeValue;
 import org.elasticsearch.common.unit.TimeValue;
 import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramInterval;
@@ -15,6 +16,7 @@ import org.elasticsearch.xpack.core.rollup.RollupActionDateHistogramGroupConfig;
 import org.elasticsearch.xpack.core.rollup.RollupActionGroupConfig;
 import org.elasticsearch.xpack.core.rollup.job.MetricConfig;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -28,6 +30,7 @@ import java.util.stream.Collectors;
 
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.ACTIONS_CANNOT_FOLLOW_SEARCHABLE_SNAPSHOT;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.COLD_PHASE;
+import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.DELETE_PHASE;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.FROZEN_PHASE;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.HOT_PHASE;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.ORDERED_VALID_COLD_ACTIONS;
@@ -37,10 +40,11 @@ import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.ORDERED_V
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.VALID_COLD_ACTIONS;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.VALID_DELETE_ACTIONS;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.VALID_HOT_ACTIONS;
-import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.VALID_PHASES;
+import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.ORDERED_VALID_PHASES;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.VALID_WARM_ACTIONS;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.WARM_PHASE;
 import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.validateAllSearchableSnapshotActionsUseSameRepository;
+import static org.elasticsearch.xpack.core.ilm.TimeseriesLifecycleType.validateMonotonicallyIncreasingPhaseTimings;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
@@ -278,12 +282,12 @@ public class TimeseriesLifecycleTypeTests extends ESTestCase {
 
     public void testGetOrderedPhases() {
         Map<String, Phase> phaseMap = new HashMap<>();
-        for (String phaseName : randomSubsetOf(randomIntBetween(0, VALID_PHASES.size()), VALID_PHASES)) {
+        for (String phaseName : randomSubsetOf(randomIntBetween(0, ORDERED_VALID_PHASES.size()), ORDERED_VALID_PHASES)) {
             phaseMap.put(phaseName, new Phase(phaseName, TimeValue.ZERO, Collections.emptyMap()));
         }
 
 
-        assertTrue(isSorted(TimeseriesLifecycleType.INSTANCE.getOrderedPhases(phaseMap), Phase::getName, VALID_PHASES));
+        assertTrue(isSorted(TimeseriesLifecycleType.INSTANCE.getOrderedPhases(phaseMap), Phase::getName, ORDERED_VALID_PHASES));
     }
 
     public void testGetOrderedPhasesInsertsMigrateAction() {
@@ -292,7 +296,7 @@ public class TimeseriesLifecycleTypeTests extends ESTestCase {
         phaseMap.put(WARM_PHASE, new Phase(WARM_PHASE, TimeValue.ZERO, Map.of()));
 
         List<Phase> orderedPhases = TimeseriesLifecycleType.INSTANCE.getOrderedPhases(phaseMap);
-        assertTrue(isSorted(orderedPhases, Phase::getName, VALID_PHASES));
+        assertTrue(isSorted(orderedPhases, Phase::getName, ORDERED_VALID_PHASES));
         Phase warmPhase = orderedPhases.get(1);
         assertThat(warmPhase, is(notNullValue()));
         assertThat(warmPhase.getActions().get(MigrateAction.NAME), is(notNullValue()));
@@ -702,6 +706,88 @@ public class TimeseriesLifecycleTypeTests extends ESTestCase {
         }
     }
 
+    public void testValidatingIncreasingAges() {
+        {
+            Phase hotPhase = new Phase(HOT_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase warmPhase = new Phase(WARM_PHASE, TimeValue.ZERO, Collections.emptyMap());
+            Phase coldPhase = new Phase(COLD_PHASE, TimeValue.ZERO, Collections.emptyMap());
+            Phase frozenPhase = new Phase(FROZEN_PHASE, TimeValue.ZERO, Collections.emptyMap());
+            Phase deletePhase = new Phase(DELETE_PHASE, TimeValue.ZERO, Collections.emptyMap());
+
+            assertFalse(Strings.hasText(validateMonotonicallyIncreasingPhaseTimings(Arrays.asList(hotPhase,
+                warmPhase, coldPhase, frozenPhase, deletePhase))));
+        }
+
+        {
+            Phase hotPhase = new Phase(HOT_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase warmPhase = new Phase(WARM_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase coldPhase = new Phase(COLD_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase frozenPhase = new Phase(FROZEN_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase deletePhase = new Phase(DELETE_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+
+            List<Phase> phases = new ArrayList<>();
+            phases.add(hotPhase);
+            if (randomBoolean()) {
+                phases.add(warmPhase);
+            }
+            if (randomBoolean()) {
+                phases.add(coldPhase);
+            }
+            if (randomBoolean()) {
+                phases.add(frozenPhase);
+            }
+            if (randomBoolean()) {
+                phases.add(deletePhase);
+            }
+            assertFalse(Strings.hasText(validateMonotonicallyIncreasingPhaseTimings(phases)));
+        }
+
+        {
+            Phase hotPhase = new Phase(HOT_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase warmPhase = new Phase(WARM_PHASE, TimeValue.ZERO, Collections.emptyMap());
+            Phase coldPhase = new Phase(COLD_PHASE, TimeValue.timeValueHours(12), Collections.emptyMap());
+            Phase frozenPhase = new Phase(FROZEN_PHASE, TimeValue.ZERO, Collections.emptyMap());
+            Phase deletePhase = new Phase(DELETE_PHASE, TimeValue.ZERO, Collections.emptyMap());
+
+            String err =
+                validateMonotonicallyIncreasingPhaseTimings(Arrays.asList(hotPhase, warmPhase, coldPhase, frozenPhase, deletePhase));
+
+            assertThat(err,
+                containsString("phases [cold] configure a [min_age] value less than the" +
+                    " [min_age] of [1d] for the [hot] phase, configuration: {cold=12h}"));
+        }
+
+        {
+            Phase hotPhase = new Phase(HOT_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase warmPhase = new Phase(WARM_PHASE, TimeValue.timeValueDays(3), Collections.emptyMap());
+            Phase coldPhase = new Phase(COLD_PHASE, null, Collections.emptyMap());
+            Phase frozenPhase = new Phase(FROZEN_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase deletePhase = new Phase(DELETE_PHASE, TimeValue.timeValueDays(2), Collections.emptyMap());
+
+            String err =
+                validateMonotonicallyIncreasingPhaseTimings(Arrays.asList(hotPhase, warmPhase, coldPhase, frozenPhase, deletePhase));
+
+            assertThat(err,
+                containsString("phases [frozen,delete] configure a [min_age] value less " +
+                    "than the [min_age] of [3d] for the [warm] phase, configuration: {frozen=1d, delete=2d}"));
+        }
+
+        {
+            Phase hotPhase = new Phase(HOT_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+            Phase warmPhase = new Phase(WARM_PHASE, TimeValue.timeValueDays(3), Collections.emptyMap());
+            Phase coldPhase = new Phase(COLD_PHASE, null, Collections.emptyMap());
+            Phase frozenPhase = new Phase(FROZEN_PHASE, TimeValue.timeValueDays(2), Collections.emptyMap());
+            Phase deletePhase = new Phase(DELETE_PHASE, TimeValue.timeValueDays(1), Collections.emptyMap());
+
+            String err =
+                validateMonotonicallyIncreasingPhaseTimings(Arrays.asList(hotPhase, warmPhase, coldPhase, frozenPhase, deletePhase));
+
+            assertThat(err,
+                containsString("phases [frozen,delete] configure a [min_age] value less than " +
+                    "the [min_age] of [3d] for the [warm] phase, configuration: {frozen=2d, delete=1d}"));
+        }
+    }
+
     private void assertNextActionName(String phaseName, String currentAction, String expectedNextAction, String... availableActionNames) {
         Map<String, LifecycleAction> availableActions = convertActionNamesToActions(availableActionNames);
         Phase phase = new Phase(phaseName, TimeValue.ZERO, availableActions);

+ 29 - 0
x-pack/plugin/ilm/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/ilm/10_basic.yml

@@ -276,3 +276,32 @@ setup:
                }
              }
            }
+
+---
+"Test increasing phase timings validated":
+
+  - do:
+      catch: /phases \[delete\] configure a \[min_age\] value less than the \[min_age\] of \[10s\] for the \[warm\] phase/
+      ilm.put_lifecycle:
+        policy: "bad_policy"
+        body: |
+           {
+             "policy": {
+               "phases": {
+                 "warm": {
+                   "min_age": "10s",
+                   "actions": {
+                     "forcemerge": {
+                       "max_num_segments": 10000
+                     }
+                   }
+                 },
+                 "delete": {
+                   "min_age": "5s",
+                   "actions": {
+                     "delete": {}
+                   }
+                 }
+               }
+             }
+           }