Skip to content

Commit c55d398

Browse files
committed
Validate health group includes and excludes
Closes gh-34360
1 parent a03fe8b commit c55d398

File tree

4 files changed

+141
-0
lines changed

4 files changed

+141
-0
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointConfiguration.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
import java.util.Iterator;
2121
import java.util.LinkedHashMap;
2222
import java.util.Map;
23+
import java.util.Set;
2324

2425
import org.springframework.beans.BeansException;
2526
import org.springframework.beans.factory.ObjectProvider;
27+
import org.springframework.beans.factory.SmartInitializingSingleton;
2628
import org.springframework.beans.factory.config.BeanPostProcessor;
2729
import org.springframework.boot.actuate.health.CompositeHealthContributor;
2830
import org.springframework.boot.actuate.health.CompositeReactiveHealthContributor;
@@ -35,16 +37,19 @@
3537
import org.springframework.boot.actuate.health.HealthIndicator;
3638
import org.springframework.boot.actuate.health.HttpCodeStatusMapper;
3739
import org.springframework.boot.actuate.health.NamedContributor;
40+
import org.springframework.boot.actuate.health.NamedContributors;
3841
import org.springframework.boot.actuate.health.ReactiveHealthContributor;
3942
import org.springframework.boot.actuate.health.ReactiveHealthIndicator;
4043
import org.springframework.boot.actuate.health.SimpleHttpCodeStatusMapper;
4144
import org.springframework.boot.actuate.health.SimpleStatusAggregator;
4245
import org.springframework.boot.actuate.health.StatusAggregator;
4346
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
47+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4448
import org.springframework.context.ApplicationContext;
4549
import org.springframework.context.annotation.Bean;
4650
import org.springframework.context.annotation.Configuration;
4751
import org.springframework.util.ClassUtils;
52+
import org.springframework.util.CollectionUtils;
4853

4954
/**
5055
* Configuration for {@link HealthEndpoint} infrastructure beans.
@@ -85,6 +90,14 @@ HealthContributorRegistry healthContributorRegistry(ApplicationContext applicati
8590
return new AutoConfiguredHealthContributorRegistry(healthContributors, groups.getNames());
8691
}
8792

93+
@Bean
94+
@ConditionalOnProperty(name = "management.endpoint.health.validate-group-membership", havingValue = "true",
95+
matchIfMissing = true)
96+
HealthEndpointGroupMembershipValidator healthEndpointGroupMembershipValidator(HealthEndpointProperties properties,
97+
HealthContributorRegistry healthContributorRegistry) {
98+
return new HealthEndpointGroupMembershipValidator(properties, healthContributorRegistry);
99+
}
100+
88101
@Bean
89102
@ConditionalOnMissingBean
90103
HealthEndpoint healthEndpoint(HealthContributorRegistry registry, HealthEndpointGroups groups,
@@ -204,4 +217,75 @@ Map<String, HealthContributor> get() {
204217

205218
}
206219

220+
/**
221+
* {@link SmartInitializingSingleton} that validates health endpoint group membership,
222+
* throwing a {@link NoSuchHealthContributorException} if an included or excluded
223+
* contributor does not exist.
224+
*/
225+
static class HealthEndpointGroupMembershipValidator implements SmartInitializingSingleton {
226+
227+
private final HealthEndpointProperties properties;
228+
229+
private final HealthContributorRegistry registry;
230+
231+
HealthEndpointGroupMembershipValidator(HealthEndpointProperties properties,
232+
HealthContributorRegistry registry) {
233+
this.properties = properties;
234+
this.registry = registry;
235+
}
236+
237+
@Override
238+
public void afterSingletonsInstantiated() {
239+
validateGroups();
240+
}
241+
242+
private void validateGroups() {
243+
this.properties.getGroup().forEach((name, group) -> {
244+
validate(group.getInclude(), "Included", name);
245+
validate(group.getExclude(), "Excluded", name);
246+
});
247+
}
248+
249+
private void validate(Set<String> names, String type, String group) {
250+
if (CollectionUtils.isEmpty(names)) {
251+
return;
252+
}
253+
for (String name : names) {
254+
if ("*".equals(name)) {
255+
return;
256+
}
257+
String[] path = name.split("/");
258+
if (!contributorExists(path)) {
259+
throw new NoSuchHealthContributorException(type, name, group);
260+
}
261+
}
262+
}
263+
264+
private boolean contributorExists(String[] path) {
265+
int pathOffset = 0;
266+
Object contributor = this.registry;
267+
while (pathOffset < path.length) {
268+
if (!(contributor instanceof NamedContributors)) {
269+
return false;
270+
}
271+
contributor = ((NamedContributors<?>) contributor).getContributor(path[pathOffset]);
272+
pathOffset++;
273+
}
274+
return (contributor != null);
275+
}
276+
277+
/**
278+
* Thrown when a contributor that does not exist is included in or excluded from a
279+
* group.
280+
*/
281+
static class NoSuchHealthContributorException extends RuntimeException {
282+
283+
NoSuchHealthContributorException(String type, String name, String group) {
284+
super(type + " health contributor '" + name + "' in group '" + group + "' does not exist");
285+
}
286+
287+
}
288+
289+
}
290+
207291
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
"UNKNOWN"
5656
]
5757
},
58+
{
59+
"name": "management.endpoint.health.validate-group-membership",
60+
"type": "java.lang.Boolean",
61+
"description": "Whether to validate health group membership on startup. Validation fails if a group includes or excludes a health contributor that does not exist.",
62+
"defaultValue": true
63+
},
5864
{
5965
"name": "management.endpoints.enabled-by-default",
6066
"type": "java.lang.Boolean",

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointAutoConfigurationTests.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525

2626
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
2727
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
28+
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException;
2829
import org.springframework.boot.actuate.endpoint.ApiVersion;
2930
import org.springframework.boot.actuate.endpoint.SecurityContext;
3031
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
3132
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
33+
import org.springframework.boot.actuate.health.CompositeHealthContributor;
3234
import org.springframework.boot.actuate.health.DefaultHealthContributorRegistry;
3335
import org.springframework.boot.actuate.health.DefaultReactiveHealthContributorRegistry;
3436
import org.springframework.boot.actuate.health.Health;
@@ -141,6 +143,41 @@ void runCreatesHealthEndpointGroups() {
141143
});
142144
}
143145

146+
@Test
147+
void runFailsWhenHealthEndpointGroupIncludesContributorThatDoesNotExist() {
148+
this.contextRunner.withUserConfiguration(CompositeHealthIndicatorConfiguration.class)
149+
.withPropertyValues("management.endpoint.health.group.ready.include=composite/b/c,nope")
150+
.run((context) -> {
151+
assertThat(context).hasFailed();
152+
assertThat(context.getStartupFailure()).isInstanceOf(NoSuchHealthContributorException.class)
153+
.hasMessage("Included health contributor 'nope' in group 'ready' does not exist");
154+
});
155+
}
156+
157+
@Test
158+
void runFailsWhenHealthEndpointGroupExcludesContributorThatDoesNotExist() {
159+
this.contextRunner
160+
.withPropertyValues("management.endpoint.health.group.ready.exclude=composite/b/d",
161+
"management.endpoint.health.group.ready.include=*")
162+
.run((context) -> {
163+
assertThat(context).hasFailed();
164+
assertThat(context.getStartupFailure()).isInstanceOf(NoSuchHealthContributorException.class)
165+
.hasMessage("Excluded health contributor 'composite/b/d' in group 'ready' does not exist");
166+
});
167+
}
168+
169+
@Test
170+
void runCreatesHealthEndpointGroupThatIncludesContributorThatDoesNotExistWhenValidationIsDisabled() {
171+
this.contextRunner
172+
.withPropertyValues("management.endpoint.health.validate-group-membership=false",
173+
"management.endpoint.health.group.ready.include=nope")
174+
.run((context) -> {
175+
HealthEndpointGroups groups = context.getBean(HealthEndpointGroups.class);
176+
assertThat(groups).isInstanceOf(AutoConfiguredHealthEndpointGroups.class);
177+
assertThat(groups.getNames()).containsOnly("ready");
178+
});
179+
}
180+
144181
@Test
145182
void runWhenHasHealthEndpointGroupsBeanDoesNotCreateAdditionalHealthEndpointGroups() {
146183
this.contextRunner.withUserConfiguration(HealthEndpointGroupsConfiguration.class)
@@ -320,6 +357,17 @@ ReactiveHealthIndicator reactiveHealthIndicator() {
320357

321358
}
322359

360+
@Configuration(proxyBeanMethods = false)
361+
static class CompositeHealthIndicatorConfiguration {
362+
363+
@Bean
364+
CompositeHealthContributor compositeHealthIndicator() {
365+
return CompositeHealthContributor.fromMap(Map.of("a", (HealthIndicator) () -> Health.up().build(), "b",
366+
CompositeHealthContributor.fromMap(Map.of("c", (HealthIndicator) () -> Health.up().build()))));
367+
}
368+
369+
}
370+
323371
@Configuration(proxyBeanMethods = false)
324372
static class StatusAggregatorConfiguration {
325373

spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,9 @@ Similarly, to create a group that excludes the database indicators from the grou
789789
exclude: "db"
790790
----
791791

792+
By default, startup will fail if a health group includes or excludes a health indicator that does not exist.
793+
To disable this behavior set configprop:management.endpoint.health.validate-group-membership[] to `false`.
794+
792795
By default, groups inherit the same `StatusAggregator` and `HttpCodeStatusMapper` settings as the system health.
793796
However, you can also define these on a per-group basis.
794797
You can also override the `show-details` and `roles` properties if required:

0 commit comments

Comments
 (0)