From c55d398f95bf1c64a55ea95e1dc8ae20e9ce7561 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 18 Apr 2023 14:53:22 +0100 Subject: [PATCH] Validate health group includes and excludes Closes gh-34360 --- .../health/HealthEndpointConfiguration.java | 84 +++++++++++++++++++ ...itional-spring-configuration-metadata.json | 6 ++ .../HealthEndpointAutoConfigurationTests.java | 48 +++++++++++ .../src/docs/asciidoc/actuator/endpoints.adoc | 3 + 4 files changed, 141 insertions(+) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointConfiguration.java index 28cdf426bc..8badfc736b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointConfiguration.java @@ -20,9 +20,11 @@ import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import org.springframework.beans.BeansException; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.actuate.health.CompositeHealthContributor; import org.springframework.boot.actuate.health.CompositeReactiveHealthContributor; @@ -35,16 +37,19 @@ import org.springframework.boot.actuate.health.HealthEndpointGroupsPostProcessor import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.actuate.health.HttpCodeStatusMapper; import org.springframework.boot.actuate.health.NamedContributor; +import org.springframework.boot.actuate.health.NamedContributors; import org.springframework.boot.actuate.health.ReactiveHealthContributor; import org.springframework.boot.actuate.health.ReactiveHealthIndicator; import org.springframework.boot.actuate.health.SimpleHttpCodeStatusMapper; import org.springframework.boot.actuate.health.SimpleStatusAggregator; import org.springframework.boot.actuate.health.StatusAggregator; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; /** * Configuration for {@link HealthEndpoint} infrastructure beans. @@ -85,6 +90,14 @@ class HealthEndpointConfiguration { return new AutoConfiguredHealthContributorRegistry(healthContributors, groups.getNames()); } + @Bean + @ConditionalOnProperty(name = "management.endpoint.health.validate-group-membership", havingValue = "true", + matchIfMissing = true) + HealthEndpointGroupMembershipValidator healthEndpointGroupMembershipValidator(HealthEndpointProperties properties, + HealthContributorRegistry healthContributorRegistry) { + return new HealthEndpointGroupMembershipValidator(properties, healthContributorRegistry); + } + @Bean @ConditionalOnMissingBean HealthEndpoint healthEndpoint(HealthContributorRegistry registry, HealthEndpointGroups groups, @@ -204,4 +217,75 @@ class HealthEndpointConfiguration { } + /** + * {@link SmartInitializingSingleton} that validates health endpoint group membership, + * throwing a {@link NoSuchHealthContributorException} if an included or excluded + * contributor does not exist. + */ + static class HealthEndpointGroupMembershipValidator implements SmartInitializingSingleton { + + private final HealthEndpointProperties properties; + + private final HealthContributorRegistry registry; + + HealthEndpointGroupMembershipValidator(HealthEndpointProperties properties, + HealthContributorRegistry registry) { + this.properties = properties; + this.registry = registry; + } + + @Override + public void afterSingletonsInstantiated() { + validateGroups(); + } + + private void validateGroups() { + this.properties.getGroup().forEach((name, group) -> { + validate(group.getInclude(), "Included", name); + validate(group.getExclude(), "Excluded", name); + }); + } + + private void validate(Set names, String type, String group) { + if (CollectionUtils.isEmpty(names)) { + return; + } + for (String name : names) { + if ("*".equals(name)) { + return; + } + String[] path = name.split("/"); + if (!contributorExists(path)) { + throw new NoSuchHealthContributorException(type, name, group); + } + } + } + + private boolean contributorExists(String[] path) { + int pathOffset = 0; + Object contributor = this.registry; + while (pathOffset < path.length) { + if (!(contributor instanceof NamedContributors)) { + return false; + } + contributor = ((NamedContributors) contributor).getContributor(path[pathOffset]); + pathOffset++; + } + return (contributor != null); + } + + /** + * Thrown when a contributor that does not exist is included in or excluded from a + * group. + */ + static class NoSuchHealthContributorException extends RuntimeException { + + NoSuchHealthContributorException(String type, String name, String group) { + super(type + " health contributor '" + name + "' in group '" + group + "' does not exist"); + } + + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 76ca59bd7c..ffe5c1d479 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -55,6 +55,12 @@ "UNKNOWN" ] }, + { + "name": "management.endpoint.health.validate-group-membership", + "type": "java.lang.Boolean", + "description": "Whether to validate health group membership on startup. Validation fails if a group includes or excludes a health contributor that does not exist.", + "defaultValue": true + }, { "name": "management.endpoints.enabled-by-default", "type": "java.lang.Boolean", diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointAutoConfigurationTests.java index 0fa0520945..6edda91c3d 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointAutoConfigurationTests.java @@ -25,10 +25,12 @@ import reactor.core.publisher.Mono; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException; import org.springframework.boot.actuate.endpoint.ApiVersion; import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.WebServerNamespace; +import org.springframework.boot.actuate.health.CompositeHealthContributor; import org.springframework.boot.actuate.health.DefaultHealthContributorRegistry; import org.springframework.boot.actuate.health.DefaultReactiveHealthContributorRegistry; import org.springframework.boot.actuate.health.Health; @@ -141,6 +143,41 @@ class HealthEndpointAutoConfigurationTests { }); } + @Test + void runFailsWhenHealthEndpointGroupIncludesContributorThatDoesNotExist() { + this.contextRunner.withUserConfiguration(CompositeHealthIndicatorConfiguration.class) + .withPropertyValues("management.endpoint.health.group.ready.include=composite/b/c,nope") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).isInstanceOf(NoSuchHealthContributorException.class) + .hasMessage("Included health contributor 'nope' in group 'ready' does not exist"); + }); + } + + @Test + void runFailsWhenHealthEndpointGroupExcludesContributorThatDoesNotExist() { + this.contextRunner + .withPropertyValues("management.endpoint.health.group.ready.exclude=composite/b/d", + "management.endpoint.health.group.ready.include=*") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).isInstanceOf(NoSuchHealthContributorException.class) + .hasMessage("Excluded health contributor 'composite/b/d' in group 'ready' does not exist"); + }); + } + + @Test + void runCreatesHealthEndpointGroupThatIncludesContributorThatDoesNotExistWhenValidationIsDisabled() { + this.contextRunner + .withPropertyValues("management.endpoint.health.validate-group-membership=false", + "management.endpoint.health.group.ready.include=nope") + .run((context) -> { + HealthEndpointGroups groups = context.getBean(HealthEndpointGroups.class); + assertThat(groups).isInstanceOf(AutoConfiguredHealthEndpointGroups.class); + assertThat(groups.getNames()).containsOnly("ready"); + }); + } + @Test void runWhenHasHealthEndpointGroupsBeanDoesNotCreateAdditionalHealthEndpointGroups() { this.contextRunner.withUserConfiguration(HealthEndpointGroupsConfiguration.class) @@ -320,6 +357,17 @@ class HealthEndpointAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class CompositeHealthIndicatorConfiguration { + + @Bean + CompositeHealthContributor compositeHealthIndicator() { + return CompositeHealthContributor.fromMap(Map.of("a", (HealthIndicator) () -> Health.up().build(), "b", + CompositeHealthContributor.fromMap(Map.of("c", (HealthIndicator) () -> Health.up().build())))); + } + + } + @Configuration(proxyBeanMethods = false) static class StatusAggregatorConfiguration { diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc index 76110a4658..195c05ab98 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc @@ -789,6 +789,9 @@ Similarly, to create a group that excludes the database indicators from the grou exclude: "db" ---- +By default, startup will fail if a health group includes or excludes a health indicator that does not exist. +To disable this behavior set configprop:management.endpoint.health.validate-group-membership[] to `false`. + By default, groups inherit the same `StatusAggregator` and `HttpCodeStatusMapper` settings as the system health. However, you can also define these on a per-group basis. You can also override the `show-details` and `roles` properties if required: