Ver Fonte

Adding impacts block to the health info API response (#84899)

This change introduces the notion of an impact to the health API indicator, and adds impacts to the shards availability
indicator.
Keith Massey há 3 anos atrás
pai
commit
99fe8dc016
17 ficheiros alterados com 483 adições e 47 exclusões
  1. 6 0
      docs/changelog/84899.yaml
  2. 9 2
      server/src/internalClusterTest/java/org/elasticsearch/health/GetHealthActionIT.java
  3. 3 1
      server/src/main/java/org/elasticsearch/cluster/coordination/InstanceHasMasterHealthIndicatorService.java
  4. 55 3
      server/src/main/java/org/elasticsearch/cluster/routing/allocation/ShardsAvailabilityHealthIndicatorService.java
  5. 34 0
      server/src/main/java/org/elasticsearch/health/HealthIndicatorImpact.java
  6. 12 3
      server/src/main/java/org/elasticsearch/health/HealthIndicatorResult.java
  7. 25 2
      server/src/main/java/org/elasticsearch/health/HealthIndicatorService.java
  8. 6 3
      server/src/main/java/org/elasticsearch/snapshots/RepositoryIntegrityHealthIndicatorService.java
  9. 168 11
      server/src/test/java/org/elasticsearch/cluster/routing/allocation/ShardsAvailabilityHealthIndicatorServiceTests.java
  10. 57 0
      server/src/test/java/org/elasticsearch/health/HealthIndicatorResultTests.java
  11. 61 0
      server/src/test/java/org/elasticsearch/health/HealthIndicatorServiceTests.java
  12. 6 5
      server/src/test/java/org/elasticsearch/health/HealthServiceTests.java
  13. 15 3
      server/src/test/java/org/elasticsearch/snapshots/RepositoryIntegrityHealthIndicatorServiceTests.java
  14. 4 3
      x-pack/plugin/ilm/src/main/java/org/elasticsearch/xpack/ilm/IlmHealthIndicatorService.java
  15. 4 3
      x-pack/plugin/ilm/src/main/java/org/elasticsearch/xpack/slm/SlmHealthIndicatorService.java
  16. 9 4
      x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/ilm/IlmHealthIndicatorServiceTests.java
  17. 9 4
      x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SlmHealthIndicatorServiceTests.java

+ 6 - 0
docs/changelog/84899.yaml

@@ -0,0 +1,6 @@
+pr: 84899
+summary: Adding impacts block to the health info API response
+area: Health
+type: feature
+issues:
+ - 84773

+ 9 - 2
server/src/internalClusterTest/java/org/elasticsearch/health/GetHealthActionIT.java

@@ -28,6 +28,7 @@ import org.elasticsearch.watcher.ResourceWatcherService;
 import org.elasticsearch.xcontent.NamedXContentRegistry;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.function.Supplier;
 
@@ -109,7 +110,12 @@ public class GetHealthActionIT extends ESIntegTestCase {
         @Override
         public HealthIndicatorResult calculate() {
             var status = clusterService.getClusterSettings().get(TEST_HEALTH_STATUS);
-            return createIndicator(status, "Health is set to [" + status + "] by test plugin", HealthIndicatorDetails.EMPTY);
+            return createIndicator(
+                status,
+                "Health is set to [" + status + "] by test plugin",
+                HealthIndicatorDetails.EMPTY,
+                Collections.emptyList()
+            );
         }
     }
 
@@ -137,7 +143,8 @@ public class GetHealthActionIT extends ESIntegTestCase {
                                 "test_component",
                                 status,
                                 "Health is set to [" + status + "] by test plugin",
-                                HealthIndicatorDetails.EMPTY
+                                HealthIndicatorDetails.EMPTY,
+                                Collections.emptyList()
                             )
                         )
                     )

+ 3 - 1
server/src/main/java/org/elasticsearch/cluster/coordination/InstanceHasMasterHealthIndicatorService.java

@@ -16,6 +16,8 @@ import org.elasticsearch.health.HealthIndicatorResult;
 import org.elasticsearch.health.HealthIndicatorService;
 import org.elasticsearch.health.HealthStatus;
 
+import java.util.Collections;
+
 import static org.elasticsearch.health.ServerHealthComponents.CLUSTER_COORDINATION;
 
 public class InstanceHasMasterHealthIndicatorService implements HealthIndicatorService {
@@ -68,6 +70,6 @@ public class InstanceHasMasterHealthIndicatorService implements HealthIndicatorS
                 }
             });
             return builder.endObject();
-        });
+        }, Collections.emptyList());
     }
 }

+ 55 - 3
server/src/main/java/org/elasticsearch/cluster/routing/allocation/ShardsAvailabilityHealthIndicatorService.java

@@ -16,12 +16,18 @@ import org.elasticsearch.cluster.routing.IndexShardRoutingTable;
 import org.elasticsearch.cluster.routing.ShardRouting;
 import org.elasticsearch.cluster.routing.UnassignedInfo;
 import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.health.HealthIndicatorImpact;
 import org.elasticsearch.health.HealthIndicatorResult;
 import org.elasticsearch.health.HealthIndicatorService;
 import org.elasticsearch.health.HealthStatus;
 import org.elasticsearch.health.SimpleHealthIndicatorDetails;
 
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Stream;
 
@@ -79,22 +85,26 @@ public class ShardsAvailabilityHealthIndicatorService implements HealthIndicator
             }
         }
 
-        return createIndicator(status.getStatus(), status.getSummary(), status.getDetails());
+        return createIndicator(status.getStatus(), status.getSummary(), status.getDetails(), status.getImpacts());
     }
 
     private static class ShardAllocationCounts {
-        private boolean available = true;
+        private boolean available = true; // This will be true even if no replicas are expected, as long as none are unavailable
         private int unassigned = 0;
         private int unassigned_new = 0;
         private int unassigned_restarting = 0;
         private int initializing = 0;
         private int started = 0;
         private int relocating = 0;
+        private Set<String> indicesWithUnavailableShards = new HashSet<>();
 
         public void increment(ShardRouting routing, NodesShutdownMetadata shutdowns) {
             boolean isNew = isUnassignedDueToNewInitialization(routing);
             boolean isRestarting = isUnassignedDueToTimelyRestart(routing, shutdowns);
             available &= routing.active() || isRestarting || isNew;
+            if ((routing.active() || isRestarting || isNew) == false) {
+                indicesWithUnavailableShards.add(routing.getIndexName());
+            }
 
             switch (routing.state()) {
                 case UNASSIGNED -> {
@@ -167,7 +177,7 @@ public class ShardsAvailabilityHealthIndicatorService implements HealthIndicator
                         createMessage(primaries.unassigned_restarting, "restarting primary", " restarting primaries"),
                         createMessage(replicas.unassigned, "unavailable replica", "unavailable replicas"),
                         createMessage(replicas.unassigned_restarting, "restarting replica", "restarting replicas")
-                    ).flatMap(Function.identity()).collect(joining(" , "))
+                    ).flatMap(Function.identity()).collect(joining(", "))
                 ).append(".");
             } else {
                 builder.append("all shards available.");
@@ -207,5 +217,47 @@ public class ShardsAvailabilityHealthIndicatorService implements HealthIndicator
                 )
             );
         }
+
+        public List<HealthIndicatorImpact> getImpacts() {
+            final List<HealthIndicatorImpact> impacts = new ArrayList<>();
+            if (primaries.indicesWithUnavailableShards.isEmpty() == false) {
+                String impactDescription = String.format(
+                    Locale.ROOT,
+                    "Cannot add data to %d %s [%s]. Searches might return incomplete results.",
+                    primaries.indicesWithUnavailableShards.size(),
+                    primaries.indicesWithUnavailableShards.size() == 1 ? "index" : "indices",
+                    getTruncatedIndicesString(primaries.indicesWithUnavailableShards)
+                );
+                impacts.add(new HealthIndicatorImpact(1, impactDescription));
+            }
+            /*
+             * It is possible that we're working with an intermediate cluster state, and that for an index we have no primary but a replica
+             * that is reported as unavailable. That replica is likely being promoted to primary. The only impact that matters at this
+             * point is the one above, which has already been reported for this index.
+             */
+            Set<String> indicesWithUnavailableReplicasOnly = new HashSet<>(replicas.indicesWithUnavailableShards);
+            indicesWithUnavailableReplicasOnly.removeAll(primaries.indicesWithUnavailableShards);
+            if (indicesWithUnavailableReplicasOnly.isEmpty() == false) {
+                String impactDescription = String.format(
+                    Locale.ROOT,
+                    "Searches might return slower than usual. Fewer redundant copies of the data exist on %d %s [%s].",
+                    indicesWithUnavailableReplicasOnly.size(),
+                    indicesWithUnavailableReplicasOnly.size() == 1 ? "index" : "indices",
+                    getTruncatedIndicesString(indicesWithUnavailableReplicasOnly)
+                );
+                impacts.add(new HealthIndicatorImpact(3, impactDescription));
+            }
+            return impacts;
+        }
+
+    }
+
+    private static String getTruncatedIndicesString(Set<String> indices) {
+        final int maxIndices = 10;
+        String truncatedIndicesString = indices.stream().limit(maxIndices).collect(joining(", "));
+        if (maxIndices < indices.size()) {
+            truncatedIndicesString = truncatedIndicesString + ", ...";
+        }
+        return truncatedIndicesString;
     }
 }

+ 34 - 0
server/src/main/java/org/elasticsearch/health/HealthIndicatorImpact.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.health;
+
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Objects;
+
+public record HealthIndicatorImpact(int severity, String impactDescription) implements ToXContentObject {
+
+    public HealthIndicatorImpact {
+        if (severity < 0) {
+            throw new IllegalArgumentException("Severity cannot be less than 0");
+        }
+        Objects.requireNonNull(impactDescription);
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field("severity", severity);
+        builder.field("description", impactDescription);
+        builder.endObject();
+        return builder;
+    }
+}

+ 12 - 3
server/src/main/java/org/elasticsearch/health/HealthIndicatorResult.java

@@ -12,10 +12,16 @@ import org.elasticsearch.xcontent.ToXContentObject;
 import org.elasticsearch.xcontent.XContentBuilder;
 
 import java.io.IOException;
+import java.util.List;
 
-public record HealthIndicatorResult(String name, String component, HealthStatus status, String summary, HealthIndicatorDetails details)
-    implements
-        ToXContentObject {
+public record HealthIndicatorResult(
+    String name,
+    String component,
+    HealthStatus status,
+    String summary,
+    HealthIndicatorDetails details,
+    List<HealthIndicatorImpact> impacts
+) implements ToXContentObject {
 
     @Override
     public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
@@ -23,6 +29,9 @@ public record HealthIndicatorResult(String name, String component, HealthStatus
         builder.field("status", status.xContentValue());
         builder.field("summary", summary);
         builder.field("details", details, params);
+        if (impacts != null && impacts.isEmpty() == false) {
+            builder.field("impacts", impacts);
+        }
         // TODO 83303: Add detail / documentation
         return builder.endObject();
     }

+ 25 - 2
server/src/main/java/org/elasticsearch/health/HealthIndicatorService.java

@@ -8,6 +8,11 @@
 
 package org.elasticsearch.health;
 
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
 /**
  * This is a service interface used to calculate health indicator from the different modules or plugins.
  */
@@ -19,7 +24,25 @@ public interface HealthIndicatorService {
 
     HealthIndicatorResult calculate();
 
-    default HealthIndicatorResult createIndicator(HealthStatus status, String summary, HealthIndicatorDetails details) {
-        return new HealthIndicatorResult(name(), component(), status, summary, details);
+    /**
+     * This method creates a HealthIndicatorResult with the given information. Note that it sorts the impacts by severity (the lower the
+     * number the higher the severity), and only keeps the 3 highest-severity impacts.
+     * @param status The status of the result
+     * @param summary The summary used in the result
+     * @param details The details used in the result
+     * @param impacts A collection of impacts. Only the 3 highest severity impacts are used in the result
+     * @return A HealthIndicatorResult built from the given information
+     */
+    default HealthIndicatorResult createIndicator(
+        HealthStatus status,
+        String summary,
+        HealthIndicatorDetails details,
+        Collection<HealthIndicatorImpact> impacts
+    ) {
+        List<HealthIndicatorImpact> impactsList = impacts.stream()
+            .sorted(Comparator.comparingInt(HealthIndicatorImpact::severity))
+            .limit(3)
+            .collect(Collectors.toList());
+        return new HealthIndicatorResult(name(), component(), status, summary, details, impactsList);
     }
 }

+ 6 - 3
server/src/main/java/org/elasticsearch/snapshots/RepositoryIntegrityHealthIndicatorService.java

@@ -17,6 +17,7 @@ import org.elasticsearch.health.HealthIndicatorService;
 import org.elasticsearch.health.SimpleHealthIndicatorDetails;
 import org.elasticsearch.repositories.RepositoryData;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -59,7 +60,7 @@ public class RepositoryIntegrityHealthIndicatorService implements HealthIndicato
         var snapshotMetadata = clusterService.state().metadata().custom(RepositoriesMetadata.TYPE, RepositoriesMetadata.EMPTY);
 
         if (snapshotMetadata.repositories().isEmpty()) {
-            return createIndicator(GREEN, "No repositories configured.", HealthIndicatorDetails.EMPTY);
+            return createIndicator(GREEN, "No repositories configured.", HealthIndicatorDetails.EMPTY, Collections.emptyList());
         }
 
         var corrupted = snapshotMetadata.repositories()
@@ -75,7 +76,8 @@ public class RepositoryIntegrityHealthIndicatorService implements HealthIndicato
             return createIndicator(
                 GREEN,
                 "No corrupted repositories.",
-                new SimpleHealthIndicatorDetails(Map.of("total_repositories", totalRepositories))
+                new SimpleHealthIndicatorDetails(Map.of("total_repositories", totalRepositories)),
+                Collections.emptyList()
             );
         }
 
@@ -91,7 +93,8 @@ public class RepositoryIntegrityHealthIndicatorService implements HealthIndicato
                     "corrupted",
                     limitSize(corrupted, 10)
                 )
-            )
+            ),
+            Collections.emptyList()
         );
     }
 

+ 168 - 11
server/src/test/java/org/elasticsearch/cluster/routing/allocation/ShardsAvailabilityHealthIndicatorServiceTests.java

@@ -19,6 +19,7 @@ import org.elasticsearch.cluster.routing.RoutingTable;
 import org.elasticsearch.cluster.routing.ShardRouting;
 import org.elasticsearch.cluster.routing.UnassignedInfo;
 import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.health.HealthIndicatorImpact;
 import org.elasticsearch.health.HealthIndicatorResult;
 import org.elasticsearch.health.HealthStatus;
 import org.elasticsearch.health.SimpleHealthIndicatorDetails;
@@ -26,6 +27,7 @@ import org.elasticsearch.index.Index;
 import org.elasticsearch.index.shard.ShardId;
 import org.elasticsearch.test.ESTestCase;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -46,6 +48,7 @@ import static org.elasticsearch.health.HealthStatus.RED;
 import static org.elasticsearch.health.HealthStatus.YELLOW;
 import static org.elasticsearch.health.ServerHealthComponents.DATA;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.oneOf;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -64,7 +67,12 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
         assertThat(
             service.calculate(),
             equalTo(
-                createExpectedResult(GREEN, "This cluster has all shards available.", Map.of("started_primaries", 2, "started_replicas", 1))
+                createExpectedResult(
+                    GREEN,
+                    "This cluster has all shards available.",
+                    Map.of("started_primaries", 2, "started_replicas", 1),
+                    Collections.emptyList()
+                )
             )
         );
     }
@@ -100,19 +108,109 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
                         unavailableReplicas.size(),
                         "started_replicas",
                         availableReplicas.size()
+                    ),
+                    List.of(
+                        new HealthIndicatorImpact(
+                            3,
+                            "Searches might return slower than usual. Fewer redundant copies of the data exist on 1 index [yellow-index]."
+                        )
                     )
                 )
             )
         );
     }
 
-    public void testShouldBeRedWhenThereAreUnassignedPrimaries() {
+    public void testShouldBeRedWhenThereAreUnassignedPrimariesAndAssignedReplicas() {
+        var clusterState = createClusterStateWith(
+            List.of(index("red-index", new ShardAllocation(randomNodeId(), UNAVAILABLE), new ShardAllocation(randomNodeId(), AVAILABLE))),
+            List.of()
+        );
+        var service = createAllocationHealthIndicatorService(clusterState);
+
+        assertThat(
+            service.calculate(),
+            equalTo(
+                createExpectedResult(
+                    RED,
+                    "This cluster has 1 unavailable primary.",
+                    Map.of("unassigned_primaries", 1, "started_replicas", 1),
+                    List.of(
+                        new HealthIndicatorImpact(1, "Cannot add data to 1 index [red-index]. Searches might return incomplete results.")
+                    )
+                )
+            )
+        );
+    }
+
+    public void testShouldBeRedWhenThereAreUnassignedPrimariesAndNoReplicas() {
         var clusterState = createClusterStateWith(List.of(index("red-index", new ShardAllocation(randomNodeId(), UNAVAILABLE))), List.of());
         var service = createAllocationHealthIndicatorService(clusterState);
 
         assertThat(
             service.calculate(),
-            equalTo(createExpectedResult(RED, "This cluster has 1 unavailable primary.", Map.of("unassigned_primaries", 1)))
+            equalTo(
+                createExpectedResult(
+                    RED,
+                    "This cluster has 1 unavailable primary.",
+                    Map.of("unassigned_primaries", 1),
+                    List.of(
+                        new HealthIndicatorImpact(1, "Cannot add data to 1 index [red-index]. Searches might return incomplete results.")
+                    )
+                )
+            )
+        );
+    }
+
+    public void testShouldBeRedWhenThereAreUnassignedPrimariesAndUnassignedReplicasOnSameIndex() {
+        var clusterState = createClusterStateWith(
+            List.of(index("red-index", new ShardAllocation(randomNodeId(), UNAVAILABLE), new ShardAllocation(randomNodeId(), UNAVAILABLE))),
+            List.of()
+        );
+        var service = createAllocationHealthIndicatorService(clusterState);
+
+        HealthIndicatorResult result = service.calculate();
+        assertEquals(RED, result.status());
+        assertEquals("This cluster has 1 unavailable primary, 1 unavailable replica.", result.summary());
+        assertEquals(1, result.impacts().size());
+        assertEquals(
+            result.impacts().get(0),
+            new HealthIndicatorImpact(1, "Cannot add data to 1 index [red-index]. Searches might return incomplete results.")
+        );
+    }
+
+    public void testShouldBeRedWhenThereAreUnassignedPrimariesAndUnassignedReplicasOnDifferentIndices() {
+        var clusterState = createClusterStateWith(
+            List.of(
+                index("red-index", new ShardAllocation(randomNodeId(), UNAVAILABLE), new ShardAllocation(randomNodeId(), AVAILABLE)),
+                index("yellow-index-1", new ShardAllocation(randomNodeId(), AVAILABLE), new ShardAllocation(randomNodeId(), UNAVAILABLE)),
+                index("yellow-index-2", new ShardAllocation(randomNodeId(), AVAILABLE), new ShardAllocation(randomNodeId(), UNAVAILABLE))
+            ),
+            List.of()
+        );
+        var service = createAllocationHealthIndicatorService(clusterState);
+
+        HealthIndicatorResult result = service.calculate();
+        assertEquals(RED, result.status());
+        assertEquals("This cluster has 1 unavailable primary, 2 unavailable replicas.", result.summary());
+        assertEquals(2, result.impacts().size());
+        assertEquals(
+            result.impacts().get(0),
+            new HealthIndicatorImpact(1, "Cannot add data to 1 index [red-index]. Searches might return incomplete results.")
+        );
+        assertThat(
+            result.impacts().get(1),
+            oneOf(
+                new HealthIndicatorImpact(
+                    3,
+                    "Searches might return slower than usual. Fewer redundant copies of the data exist on 2 indices [yellow-index-1, "
+                        + "yellow-index-2]."
+                ),
+                new HealthIndicatorImpact(
+                    3,
+                    "Searches might return slower than usual. Fewer redundant copies of the data exist on 2 indices [yellow-index-2, "
+                        + "yellow-index-1]."
+                )
+            )
         );
     }
 
@@ -135,7 +233,28 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
                 createExpectedResult(
                     GREEN,
                     "This cluster has 1 restarting replica.",
-                    Map.of("started_primaries", 1, "restarting_replicas", 1)
+                    Map.of("started_primaries", 1, "restarting_replicas", 1),
+                    Collections.emptyList()
+                )
+            )
+        );
+    }
+
+    public void testShouldBeGreenWhenThereAreNoReplicasExpected() {
+        var clusterState = createClusterStateWith(
+            List.of(index("primaries-only-index", new ShardAllocation(randomNodeId(), AVAILABLE))),
+            List.of(new NodeShutdown("node-0", RESTART, 60))
+        );
+        var service = createAllocationHealthIndicatorService(clusterState);
+
+        assertThat(
+            service.calculate(),
+            equalTo(
+                createExpectedResult(
+                    GREEN,
+                    "This cluster has all shards available.",
+                    Map.of("started_primaries", 1),
+                    Collections.emptyList()
                 )
             )
         );
@@ -160,7 +279,14 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
                 createExpectedResult(
                     YELLOW,
                     "This cluster has 1 unavailable replica.",
-                    Map.of("started_primaries", 1, "unassigned_replicas", 1)
+                    Map.of("started_primaries", 1, "unassigned_replicas", 1),
+                    List.of(
+                        new HealthIndicatorImpact(
+                            3,
+                            "Searches might return slower than usual. Fewer redundant copies of the data exist on 1 index "
+                                + "[restarting-index]."
+                        )
+                    )
                 )
             )
         );
@@ -175,7 +301,14 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
 
         assertThat(
             service.calculate(),
-            equalTo(createExpectedResult(GREEN, "This cluster has 1 creating primary.", Map.of("creating_primaries", 1)))
+            equalTo(
+                createExpectedResult(
+                    GREEN,
+                    "This cluster has 1 creating primary.",
+                    Map.of("creating_primaries", 1),
+                    Collections.emptyList()
+                )
+            )
         );
     }
 
@@ -188,11 +321,18 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
 
         assertThat(
             service.calculate(),
-            equalTo(createExpectedResult(GREEN, "This cluster has 1 restarting primary.", Map.of("restarting_primaries", 1)))
+            equalTo(
+                createExpectedResult(
+                    GREEN,
+                    "This cluster has 1 restarting primary.",
+                    Map.of("restarting_primaries", 1),
+                    Collections.emptyList()
+                )
+            )
         );
     }
 
-    public void testShouldBeRedWhenRestartingPrimariesReachedAllocationDelay() {
+    public void testShouldBeRedWhenRestartingPrimariesReachedAllocationDelayAndNoReplicas() {
         var clusterState = createClusterStateWith(
             List.of(
                 index(
@@ -206,12 +346,29 @@ public class ShardsAvailabilityHealthIndicatorServiceTests extends ESTestCase {
 
         assertThat(
             service.calculate(),
-            equalTo(createExpectedResult(RED, "This cluster has 1 unavailable primary.", Map.of("unassigned_primaries", 1)))
+            equalTo(
+                createExpectedResult(
+                    RED,
+                    "This cluster has 1 unavailable primary.",
+                    Map.of("unassigned_primaries", 1),
+                    List.of(
+                        new HealthIndicatorImpact(
+                            1,
+                            "Cannot add data to 1 index [restarting-index]. Searches might return incomplete results."
+                        )
+                    )
+                )
+            )
         );
     }
 
-    private HealthIndicatorResult createExpectedResult(HealthStatus status, String summary, Map<String, Object> details) {
-        return new HealthIndicatorResult(NAME, DATA, status, summary, new SimpleHealthIndicatorDetails(addDefaults(details)));
+    private HealthIndicatorResult createExpectedResult(
+        HealthStatus status,
+        String summary,
+        Map<String, Object> details,
+        List<HealthIndicatorImpact> impacts
+    ) {
+        return new HealthIndicatorResult(NAME, DATA, status, summary, new SimpleHealthIndicatorDetails(addDefaults(details)), impacts);
     }
 
     private static ClusterState createClusterStateWith(List<IndexRoutingTable> indexes, List<NodeShutdown> nodeShutdowns) {

+ 57 - 0
server/src/test/java/org/elasticsearch/health/HealthIndicatorResultTests.java

@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.health;
+
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class HealthIndicatorResultTests extends ESTestCase {
+    public void testToXContent() throws Exception {
+        String name = randomAlphaOfLength(10);
+        String component = randomAlphaOfLength(10);
+        HealthStatus status = randomFrom(HealthStatus.RED, HealthStatus.YELLOW, HealthStatus.GREEN);
+        String summary = randomAlphaOfLength(20);
+        Map<String, Object> detailsMap = new HashMap<>();
+        detailsMap.put("key", "value");
+        HealthIndicatorDetails details = new SimpleHealthIndicatorDetails(detailsMap);
+        List<HealthIndicatorImpact> impacts = new ArrayList<>();
+        int impact1Severity = randomIntBetween(1, 5);
+        String impact1Description = randomAlphaOfLength(30);
+        impacts.add(new HealthIndicatorImpact(impact1Severity, impact1Description));
+        int impact2Severity = randomIntBetween(1, 5);
+        String impact2Description = randomAlphaOfLength(30);
+        impacts.add(new HealthIndicatorImpact(impact2Severity, impact2Description));
+        HealthIndicatorResult result = new HealthIndicatorResult(name, component, status, summary, details, impacts);
+        XContentBuilder builder = XContentFactory.jsonBuilder().prettyPrint();
+        result.toXContent(builder, ToXContent.EMPTY_PARAMS);
+        Map<String, Object> xContentMap = XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType()).v2();
+        assertEquals(status.xContentValue(), xContentMap.get("status"));
+        assertEquals(summary, xContentMap.get("summary"));
+        assertEquals(detailsMap, xContentMap.get("details"));
+        List<Map<String, Object>> expectedImpacts = new ArrayList<>();
+        Map<String, Object> expectedImpact1 = new HashMap<>();
+        expectedImpact1.put("severity", impact1Severity);
+        expectedImpact1.put("description", impact1Description);
+        Map<String, Object> expectedImpact2 = new HashMap<>();
+        expectedImpact2.put("severity", impact2Severity);
+        expectedImpact2.put("description", impact2Description);
+        expectedImpacts.add(expectedImpact1);
+        expectedImpacts.add(expectedImpact2);
+        assertEquals(expectedImpacts, xContentMap.get("impacts"));
+    }
+}

+ 61 - 0
server/src/test/java/org/elasticsearch/health/HealthIndicatorServiceTests.java

@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.health;
+
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class HealthIndicatorServiceTests extends ESTestCase {
+
+    public void testImpactsOrderAndLimit() {
+        // This test adds 23 impacts, and asserts that only the highest-severity 3 come back from HealthIndicatorService.createIndicator()
+        HealthIndicatorService service = getTestHealthIndicatorService();
+        HealthStatus status = randomFrom(HealthStatus.RED, HealthStatus.YELLOW, HealthStatus.GREEN);
+        Set<HealthIndicatorImpact> impacts = new HashSet<>();
+        for (int i = 0; i < 10; i++) {
+            impacts.add(new HealthIndicatorImpact(randomIntBetween(5, 20), randomAlphaOfLength(20)));
+        }
+        HealthIndicatorImpact impact1 = new HealthIndicatorImpact(1, randomAlphaOfLength(20));
+        HealthIndicatorImpact impact2 = new HealthIndicatorImpact(2, randomAlphaOfLength(20));
+        HealthIndicatorImpact impact3 = new HealthIndicatorImpact(3, randomAlphaOfLength(20));
+        impacts.add(impact2);
+        impacts.add(impact1);
+        impacts.add(impact3);
+        for (int i = 0; i < 10; i++) {
+            impacts.add(new HealthIndicatorImpact(randomIntBetween(5, 20), randomAlphaOfLength(20)));
+        }
+        HealthIndicatorResult result = service.createIndicator(status, randomAlphaOfLength(20), HealthIndicatorDetails.EMPTY, impacts);
+        List<HealthIndicatorImpact> outputImpacts = result.impacts();
+        assertEquals(3, outputImpacts.size());
+        List<HealthIndicatorImpact> expectedImpacts = List.of(impact1, impact2, impact3);
+        assertEquals(expectedImpacts, outputImpacts);
+    }
+
+    private HealthIndicatorService getTestHealthIndicatorService() {
+        return new HealthIndicatorService() {
+            @Override
+            public String name() {
+                return null;
+            }
+
+            @Override
+            public String component() {
+                return null;
+            }
+
+            @Override
+            public HealthIndicatorResult calculate() {
+                return null;
+            }
+        };
+    }
+}

+ 6 - 5
server/src/test/java/org/elasticsearch/health/HealthServiceTests.java

@@ -10,6 +10,7 @@ package org.elasticsearch.health;
 
 import org.elasticsearch.test.ESTestCase;
 
+import java.util.Collections;
 import java.util.List;
 
 import static org.elasticsearch.health.HealthStatus.GREEN;
@@ -23,9 +24,9 @@ public class HealthServiceTests extends ESTestCase {
 
     public void testShouldReturnGroupedIndicators() {
 
-        var indicator1 = new HealthIndicatorResult("indicator1", "component1", GREEN, null, null);
-        var indicator2 = new HealthIndicatorResult("indicator2", "component1", YELLOW, null, null);
-        var indicator3 = new HealthIndicatorResult("indicator3", "component2", GREEN, null, null);
+        var indicator1 = new HealthIndicatorResult("indicator1", "component1", GREEN, null, null, null);
+        var indicator2 = new HealthIndicatorResult("indicator2", "component1", YELLOW, null, null, null);
+        var indicator3 = new HealthIndicatorResult("indicator3", "component2", GREEN, null, null, null);
 
         var service = new HealthService(
             List.of(
@@ -52,8 +53,8 @@ public class HealthServiceTests extends ESTestCase {
 
     public void testDuplicateIndicatorNamess() {
         // Same component, same indicator name, should throw exception:
-        var indicator1 = new HealthIndicatorResult("indicator1", "component1", GREEN, null, null);
-        var indicator2 = new HealthIndicatorResult("indicator1", "component1", YELLOW, null, null);
+        var indicator1 = new HealthIndicatorResult("indicator1", "component1", GREEN, null, null, Collections.emptyList());
+        var indicator2 = new HealthIndicatorResult("indicator1", "component1", YELLOW, null, null, Collections.emptyList());
         expectThrows(AssertionError.class, () -> HealthService.createComponentFromIndicators(List.of(indicator1, indicator2)));
     }
 

+ 15 - 3
server/src/test/java/org/elasticsearch/snapshots/RepositoryIntegrityHealthIndicatorServiceTests.java

@@ -20,6 +20,7 @@ import org.elasticsearch.health.HealthIndicatorResult;
 import org.elasticsearch.health.SimpleHealthIndicatorDetails;
 import org.elasticsearch.test.ESTestCase;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -49,7 +50,8 @@ public class RepositoryIntegrityHealthIndicatorServiceTests extends ESTestCase {
                     SNAPSHOT,
                     GREEN,
                     "No corrupted repositories.",
-                    new SimpleHealthIndicatorDetails(Map.of("total_repositories", repos.size()))
+                    new SimpleHealthIndicatorDetails(Map.of("total_repositories", repos.size())),
+                    Collections.emptyList()
                 )
             )
         );
@@ -73,7 +75,8 @@ public class RepositoryIntegrityHealthIndicatorServiceTests extends ESTestCase {
                     "Detected [1] corrupted repositories: [corrupted-repo].",
                     new SimpleHealthIndicatorDetails(
                         Map.of("total_repositories", repos.size(), "corrupted_repositories", 1, "corrupted", List.of("corrupted-repo"))
-                    )
+                    ),
+                    Collections.emptyList()
                 )
             )
         );
@@ -85,7 +88,16 @@ public class RepositoryIntegrityHealthIndicatorServiceTests extends ESTestCase {
 
         assertThat(
             service.calculate(),
-            equalTo(new HealthIndicatorResult(NAME, SNAPSHOT, GREEN, "No repositories configured.", HealthIndicatorDetails.EMPTY))
+            equalTo(
+                new HealthIndicatorResult(
+                    NAME,
+                    SNAPSHOT,
+                    GREEN,
+                    "No repositories configured.",
+                    HealthIndicatorDetails.EMPTY,
+                    Collections.emptyList()
+                )
+            )
         );
     }
 

+ 4 - 3
x-pack/plugin/ilm/src/main/java/org/elasticsearch/xpack/ilm/IlmHealthIndicatorService.java

@@ -15,6 +15,7 @@ import org.elasticsearch.health.SimpleHealthIndicatorDetails;
 import org.elasticsearch.xpack.core.ilm.IndexLifecycleMetadata;
 import org.elasticsearch.xpack.core.ilm.OperationMode;
 
+import java.util.Collections;
 import java.util.Map;
 
 import static org.elasticsearch.health.HealthStatus.GREEN;
@@ -53,11 +54,11 @@ public class IlmHealthIndicatorService implements HealthIndicatorService {
     public HealthIndicatorResult calculate() {
         var ilmMetadata = clusterService.state().metadata().custom(IndexLifecycleMetadata.TYPE, IndexLifecycleMetadata.EMPTY);
         if (ilmMetadata.getPolicyMetadatas().isEmpty()) {
-            return createIndicator(GREEN, "No policies configured", createDetails(ilmMetadata));
+            return createIndicator(GREEN, "No policies configured", createDetails(ilmMetadata), Collections.emptyList());
         } else if (ilmMetadata.getOperationMode() != OperationMode.RUNNING) {
-            return createIndicator(YELLOW, "ILM is not running", createDetails(ilmMetadata));
+            return createIndicator(YELLOW, "ILM is not running", createDetails(ilmMetadata), Collections.emptyList());
         } else {
-            return createIndicator(GREEN, "ILM is running", createDetails(ilmMetadata));
+            return createIndicator(GREEN, "ILM is running", createDetails(ilmMetadata), Collections.emptyList());
         }
     }
 

+ 4 - 3
x-pack/plugin/ilm/src/main/java/org/elasticsearch/xpack/slm/SlmHealthIndicatorService.java

@@ -15,6 +15,7 @@ import org.elasticsearch.health.SimpleHealthIndicatorDetails;
 import org.elasticsearch.xpack.core.ilm.OperationMode;
 import org.elasticsearch.xpack.core.slm.SnapshotLifecycleMetadata;
 
+import java.util.Collections;
 import java.util.Map;
 
 import static org.elasticsearch.health.HealthStatus.GREEN;
@@ -53,11 +54,11 @@ public class SlmHealthIndicatorService implements HealthIndicatorService {
     public HealthIndicatorResult calculate() {
         var slmMetadata = clusterService.state().metadata().custom(SnapshotLifecycleMetadata.TYPE, SnapshotLifecycleMetadata.EMPTY);
         if (slmMetadata.getSnapshotConfigurations().isEmpty()) {
-            return createIndicator(GREEN, "No policies configured", createDetails(slmMetadata));
+            return createIndicator(GREEN, "No policies configured", createDetails(slmMetadata), Collections.emptyList());
         } else if (slmMetadata.getOperationMode() != OperationMode.RUNNING) {
-            return createIndicator(YELLOW, "SLM is not running", createDetails(slmMetadata));
+            return createIndicator(YELLOW, "SLM is not running", createDetails(slmMetadata), Collections.emptyList());
         } else {
-            return createIndicator(GREEN, "SLM is running", createDetails(slmMetadata));
+            return createIndicator(GREEN, "SLM is running", createDetails(slmMetadata), Collections.emptyList());
         }
     }
 

+ 9 - 4
x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/ilm/IlmHealthIndicatorServiceTests.java

@@ -18,6 +18,7 @@ import org.elasticsearch.xpack.core.ilm.IndexLifecycleMetadata;
 import org.elasticsearch.xpack.core.ilm.LifecyclePolicy;
 import org.elasticsearch.xpack.core.ilm.LifecyclePolicyMetadata;
 
+import java.util.Collections;
 import java.util.Map;
 
 import static org.elasticsearch.health.HealthStatus.GREEN;
@@ -45,7 +46,8 @@ public class IlmHealthIndicatorServiceTests extends ESTestCase {
                     DATA,
                     GREEN,
                     "ILM is running",
-                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", RUNNING, "policies", 1))
+                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", RUNNING, "policies", 1)),
+                    Collections.emptyList()
                 )
             )
         );
@@ -64,7 +66,8 @@ public class IlmHealthIndicatorServiceTests extends ESTestCase {
                     DATA,
                     YELLOW,
                     "ILM is not running",
-                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", status, "policies", 1))
+                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", status, "policies", 1)),
+                    Collections.emptyList()
                 )
             )
         );
@@ -83,7 +86,8 @@ public class IlmHealthIndicatorServiceTests extends ESTestCase {
                     DATA,
                     GREEN,
                     "No policies configured",
-                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", status, "policies", 0))
+                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", status, "policies", 0)),
+                    Collections.emptyList()
                 )
             )
         );
@@ -101,7 +105,8 @@ public class IlmHealthIndicatorServiceTests extends ESTestCase {
                     DATA,
                     GREEN,
                     "No policies configured",
-                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", RUNNING, "policies", 0))
+                    new SimpleHealthIndicatorDetails(Map.of("ilm_status", RUNNING, "policies", 0)),
+                    Collections.emptyList()
                 )
             )
         );

+ 9 - 4
x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SlmHealthIndicatorServiceTests.java

@@ -18,6 +18,7 @@ import org.elasticsearch.xpack.core.slm.SnapshotLifecycleMetadata;
 import org.elasticsearch.xpack.core.slm.SnapshotLifecyclePolicy;
 import org.elasticsearch.xpack.core.slm.SnapshotLifecyclePolicyMetadata;
 
+import java.util.Collections;
 import java.util.Map;
 
 import static org.elasticsearch.health.HealthStatus.GREEN;
@@ -45,7 +46,8 @@ public class SlmHealthIndicatorServiceTests extends ESTestCase {
                     SNAPSHOT,
                     GREEN,
                     "SLM is running",
-                    new SimpleHealthIndicatorDetails(Map.of("slm_status", RUNNING, "policies", 1))
+                    new SimpleHealthIndicatorDetails(Map.of("slm_status", RUNNING, "policies", 1)),
+                    Collections.emptyList()
                 )
             )
         );
@@ -64,7 +66,8 @@ public class SlmHealthIndicatorServiceTests extends ESTestCase {
                     SNAPSHOT,
                     YELLOW,
                     "SLM is not running",
-                    new SimpleHealthIndicatorDetails(Map.of("slm_status", status, "policies", 1))
+                    new SimpleHealthIndicatorDetails(Map.of("slm_status", status, "policies", 1)),
+                    Collections.emptyList()
                 )
             )
         );
@@ -83,7 +86,8 @@ public class SlmHealthIndicatorServiceTests extends ESTestCase {
                     SNAPSHOT,
                     GREEN,
                     "No policies configured",
-                    new SimpleHealthIndicatorDetails(Map.of("slm_status", status, "policies", 0))
+                    new SimpleHealthIndicatorDetails(Map.of("slm_status", status, "policies", 0)),
+                    Collections.emptyList()
                 )
             )
         );
@@ -101,7 +105,8 @@ public class SlmHealthIndicatorServiceTests extends ESTestCase {
                     SNAPSHOT,
                     GREEN,
                     "No policies configured",
-                    new SimpleHealthIndicatorDetails(Map.of("slm_status", RUNNING, "policies", 0))
+                    new SimpleHealthIndicatorDetails(Map.of("slm_status", RUNNING, "policies", 0)),
+                    Collections.emptyList()
                 )
             )
         );