Skip to content

Commit 8fd9eb7

Browse files
mbhavephilwebb
andcommitted
Allow part of a composite contributor in a health group
Closes spring-projectsgh-23027 Co-authored-by: Phillip Webb <[email protected]>
1 parent fd2fbcb commit 8fd9eb7

File tree

10 files changed

+226
-24
lines changed

10 files changed

+226
-24
lines changed

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

+19-3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* Member predicate that matches based on {@code include} and {@code exclude} sets.
2727
*
2828
* @author Phillip Webb
29+
* @author Madhura Bhave
2930
*/
3031
class IncludeExcludeGroupMemberPredicate implements Predicate<String> {
3132

@@ -40,15 +41,30 @@ class IncludeExcludeGroupMemberPredicate implements Predicate<String> {
4041

4142
@Override
4243
public boolean test(String name) {
44+
return testCleanName(clean(name));
45+
}
46+
47+
private boolean testCleanName(String name) {
4348
return isIncluded(name) && !isExcluded(name);
4449
}
4550

4651
private boolean isIncluded(String name) {
47-
return this.include.isEmpty() || this.include.contains("*") || this.include.contains(clean(name));
52+
return this.include.isEmpty() || this.include.contains("*") || isIncludedName(name);
53+
}
54+
55+
private boolean isIncludedName(String name) {
56+
if (this.include.contains(name)) {
57+
return true;
58+
}
59+
if (name.contains("/")) {
60+
String parent = name.substring(0, name.lastIndexOf("/"));
61+
return isIncludedName(parent);
62+
}
63+
return false;
4864
}
4965

5066
private boolean isExcluded(String name) {
51-
return this.exclude.contains("*") || this.exclude.contains(clean(name));
67+
return this.exclude.contains("*") || this.exclude.contains(name);
5268
}
5369

5470
private Set<String> clean(Set<String> names) {
@@ -60,7 +76,7 @@ private Set<String> clean(Set<String> names) {
6076
}
6177

6278
private String clean(String name) {
63-
return name.trim();
79+
return (name != null) ? name.trim() : null;
6480
}
6581

6682
}

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

+18
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,24 @@ void testWhenExtraWhitespaceAcceptsTrimmedVersion() {
9494
assertThat(predicate).accepts("myEndpoint").rejects("d");
9595
}
9696

97+
@Test
98+
void testWhenSpecifiedIncludeWithSlash() {
99+
Predicate<String> predicate = include("test/a").exclude();
100+
assertThat(predicate).accepts("test/a").rejects("test").rejects("test/b");
101+
}
102+
103+
@Test
104+
void specifiedIncludeShouldIncludeNested() {
105+
Predicate<String> predicate = include("test").exclude();
106+
assertThat(predicate).accepts("test/a/d").accepts("test/b").rejects("foo");
107+
}
108+
109+
@Test
110+
void specifiedIncludeShouldNotIncludeExcludedNested() {
111+
Predicate<String> predicate = include("test").exclude("test/b");
112+
assertThat(predicate).accepts("test/a").rejects("test/b").rejects("foo");
113+
}
114+
97115
private Builder include(String... include) {
98116
return new Builder(include);
99117
}

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

+33-18
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.springframework.boot.actuate.endpoint.SecurityContext;
2626
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
2727
import org.springframework.util.Assert;
28+
import org.springframework.util.StringUtils;
2829

2930
/**
3031
* Base class for health endpoints and health endpoint extensions.
@@ -86,8 +87,12 @@ private HealthResult<T> getHealth(ApiVersion apiVersion, HealthEndpointGroup gro
8687
return null;
8788
}
8889
Object contributor = getContributor(path, pathOffset);
90+
if (contributor == null) {
91+
return null;
92+
}
93+
String name = getName(path, pathOffset);
8994
Set<String> groupNames = isSystemHealth ? this.groups.getNames() : null;
90-
T health = getContribution(apiVersion, group, contributor, showComponents, showDetails, groupNames, false);
95+
T health = getContribution(apiVersion, group, name, contributor, showComponents, showDetails, groupNames);
9196
return (health != null) ? new HealthResult<>(health, group) : null;
9297
}
9398

@@ -104,29 +109,39 @@ private Object getContributor(String[] path, int pathOffset) {
104109
return contributor;
105110
}
106111

112+
private String getName(String[] path, int pathOffset) {
113+
StringBuilder name = new StringBuilder();
114+
while (pathOffset < path.length) {
115+
name.append((name.length() != 0) ? "/" : "");
116+
name.append(path[pathOffset]);
117+
pathOffset++;
118+
}
119+
return name.toString();
120+
}
121+
107122
@SuppressWarnings("unchecked")
108-
private T getContribution(ApiVersion apiVersion, HealthEndpointGroup group, Object contributor,
109-
boolean showComponents, boolean showDetails, Set<String> groupNames, boolean isNested) {
123+
private T getContribution(ApiVersion apiVersion, HealthEndpointGroup group, String name, Object contributor,
124+
boolean showComponents, boolean showDetails, Set<String> groupNames) {
110125
if (contributor instanceof NamedContributors) {
111-
return getAggregateHealth(apiVersion, group, (NamedContributors<C>) contributor, showComponents,
112-
showDetails, groupNames, isNested);
126+
return getAggregateContribution(apiVersion, group, name, (NamedContributors<C>) contributor, showComponents,
127+
showDetails, groupNames);
113128
}
114-
return (contributor != null) ? getHealth((C) contributor, showDetails) : null;
129+
if (contributor != null && (name.isEmpty() || group.isMember(name))) {
130+
return getHealth((C) contributor, showDetails);
131+
}
132+
return null;
115133
}
116134

117-
private T getAggregateHealth(ApiVersion apiVersion, HealthEndpointGroup group,
118-
NamedContributors<C> namedContributors, boolean showComponents, boolean showDetails, Set<String> groupNames,
119-
boolean isNested) {
135+
private T getAggregateContribution(ApiVersion apiVersion, HealthEndpointGroup group, String name,
136+
NamedContributors<C> namedContributors, boolean showComponents, boolean showDetails,
137+
Set<String> groupNames) {
138+
String prefix = (StringUtils.hasText(name)) ? name + "/" : "";
120139
Map<String, T> contributions = new LinkedHashMap<>();
121-
for (NamedContributor<C> namedContributor : namedContributors) {
122-
String name = namedContributor.getName();
123-
C contributor = namedContributor.getContributor();
124-
if (group.isMember(name) || isNested) {
125-
T contribution = getContribution(apiVersion, group, contributor, showComponents, showDetails, null,
126-
true);
127-
if (contribution != null) {
128-
contributions.put(name, contribution);
129-
}
140+
for (NamedContributor<C> child : namedContributors) {
141+
T contribution = getContribution(apiVersion, group, prefix + child.getName(), child.getContributor(),
142+
showComponents, showDetails, null);
143+
if (contribution != null) {
144+
contributions.put(child.getName(), contribution);
130145
}
131146
}
132147
if (contributions.isEmpty()) {

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,19 @@ abstract class NamedContributorsMapAdapter<V, C> implements NamedContributors<C>
4343
NamedContributorsMapAdapter(Map<String, V> map, Function<V, ? extends C> valueAdapter) {
4444
Assert.notNull(map, "Map must not be null");
4545
Assert.notNull(valueAdapter, "ValueAdapter must not be null");
46-
map.keySet().forEach((key) -> Assert.notNull(key, "Map must not contain null keys"));
46+
map.keySet().forEach(this::validateKey);
4747
map.values().stream().map(valueAdapter)
4848
.forEach((value) -> Assert.notNull(value, "Map must not contain null values"));
4949
this.map = Collections.unmodifiableMap(new LinkedHashMap<>(map));
5050
this.valueAdapter = valueAdapter;
5151
}
5252

53+
private void validateKey(String value) {
54+
Assert.notNull(value, "Map must not contain null keys");
55+
Assert.isTrue(!value.contains("/"), "Map keys must not contain a '/'");
56+
57+
}
58+
5359
@Override
5460
public Iterator<NamedContributor<C>> iterator() {
5561
Iterator<Entry<String, V>> iterator = this.map.entrySet().iterator();

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

+87
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.Collections;
2020
import java.util.LinkedHashMap;
2121
import java.util.Map;
22+
import java.util.function.Predicate;
2223

2324
import org.junit.jupiter.api.Test;
2425

@@ -224,6 +225,92 @@ void getHealthWhenGroupContainsCompositeContributorReturnsHealth() {
224225
assertThat(health.getComponents()).containsKey("test");
225226
}
226227

228+
@Test
229+
void getHealthWhenGroupContainsComponentOfCompositeContributorReturnsHealth() {
230+
CompositeHealth health = getCompositeHealth((name) -> name.equals("test/spring-1"));
231+
assertThat(health.getComponents()).containsKey("test");
232+
CompositeHealth test = (CompositeHealth) health.getComponents().get("test");
233+
assertThat(test.getComponents()).containsKey("spring-1");
234+
assertThat(test.getComponents()).doesNotContainKey("spring-2");
235+
assertThat(test.getComponents()).doesNotContainKey("test");
236+
}
237+
238+
@Test
239+
void getHealthWhenGroupExcludesComponentOfCompositeContributorReturnsHealth() {
240+
CompositeHealth health = getCompositeHealth(
241+
(name) -> name.startsWith("test/") && !name.equals("test/spring-2"));
242+
assertThat(health.getComponents()).containsKey("test");
243+
CompositeHealth test = (CompositeHealth) health.getComponents().get("test");
244+
assertThat(test.getComponents()).containsKey("spring-1");
245+
assertThat(test.getComponents()).doesNotContainKey("spring-2");
246+
}
247+
248+
@Test
249+
void getHealthForPathWhenGroupContainsComponentOfCompositeContributorReturnsHealth() {
250+
Map<String, C> contributors = new LinkedHashMap<>();
251+
contributors.put("spring-1", createNestedHealthContributor("spring-1"));
252+
contributors.put("spring-2", createNestedHealthContributor("spring-2"));
253+
C compositeContributor = createCompositeContributor(contributors);
254+
this.registry.registerContributor("test", compositeContributor);
255+
TestHealthEndpointGroup testGroup = new TestHealthEndpointGroup(
256+
(name) -> name.startsWith("test") && !name.equals("test/spring-1/b"));
257+
HealthEndpointGroups groups = HealthEndpointGroups.of(this.primaryGroup,
258+
Collections.singletonMap("testGroup", testGroup));
259+
HealthResult<T> result = create(this.registry, groups).getHealth(ApiVersion.V3, null, SecurityContext.NONE,
260+
false, "testGroup", "test");
261+
CompositeHealth health = (CompositeHealth) getHealth(result);
262+
assertThat(health.getComponents()).containsKey("spring-1");
263+
assertThat(health.getComponents()).containsKey("spring-2");
264+
CompositeHealth spring1 = (CompositeHealth) health.getComponents().get("spring-1");
265+
CompositeHealth spring2 = (CompositeHealth) health.getComponents().get("spring-2");
266+
assertThat(spring1.getComponents()).containsKey("a");
267+
assertThat(spring1.getComponents()).containsKey("c");
268+
assertThat(spring1.getComponents()).doesNotContainKey("b");
269+
assertThat(spring2.getComponents()).containsKey("a");
270+
assertThat(spring2.getComponents()).containsKey("c");
271+
assertThat(spring2.getComponents()).containsKey("b");
272+
}
273+
274+
@Test
275+
void getHealthForComponentPathWhenNotPartOfGroup() {
276+
Map<String, C> contributors = new LinkedHashMap<>();
277+
contributors.put("spring-1", createNestedHealthContributor("spring-1"));
278+
contributors.put("spring-2", createNestedHealthContributor("spring-2"));
279+
C compositeContributor = createCompositeContributor(contributors);
280+
this.registry.registerContributor("test", compositeContributor);
281+
TestHealthEndpointGroup testGroup = new TestHealthEndpointGroup(
282+
(name) -> name.startsWith("test") && !name.equals("test/spring-1/b"));
283+
HealthEndpointGroups groups = HealthEndpointGroups.of(this.primaryGroup,
284+
Collections.singletonMap("testGroup", testGroup));
285+
HealthResult<T> result = create(this.registry, groups).getHealth(ApiVersion.V3, null, SecurityContext.NONE,
286+
false, "testGroup", "test", "spring-1", "b");
287+
assertThat(result).isNull();
288+
}
289+
290+
private CompositeHealth getCompositeHealth(Predicate<String> memberPredicate) {
291+
C contributor1 = createContributor(this.up);
292+
C contributor2 = createContributor(this.down);
293+
Map<String, C> contributors = new LinkedHashMap<>();
294+
contributors.put("spring-1", contributor1);
295+
contributors.put("spring-2", contributor2);
296+
C compositeContributor = createCompositeContributor(contributors);
297+
this.registry.registerContributor("test", compositeContributor);
298+
TestHealthEndpointGroup testGroup = new TestHealthEndpointGroup(memberPredicate);
299+
HealthEndpointGroups groups = HealthEndpointGroups.of(this.primaryGroup,
300+
Collections.singletonMap("testGroup", testGroup));
301+
HealthResult<T> result = create(this.registry, groups).getHealth(ApiVersion.V3, null, SecurityContext.NONE,
302+
false, "testGroup");
303+
return (CompositeHealth) getHealth(result);
304+
}
305+
306+
private C createNestedHealthContributor(String name) {
307+
Map<String, C> map = new LinkedHashMap<>();
308+
map.put("a", createContributor(Health.up().withDetail("hello", name + "-a").build()));
309+
map.put("b", createContributor(Health.up().withDetail("hello", name + "-b").build()));
310+
map.put("c", createContributor(Health.up().withDetail("hello", name + "-c").build()));
311+
return createCompositeContributor(map);
312+
}
313+
227314
@Test
228315
void getHealthWhenGroupHasAdditionalPath() {
229316
this.registry.registerContributor("test", createContributor(this.up));

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

+8
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ void createWhenMapContainsNullKeyThrowsException() {
6464
.withMessage("Map must not contain null keys");
6565
}
6666

67+
@Test
68+
void createWhenMapContainsKeyWithSlashThrowsException() {
69+
assertThatIllegalArgumentException()
70+
.isThrownBy(() -> new TestNamedContributorsMapAdapter<>(Collections.singletonMap("test/key", "test"),
71+
Function.identity()))
72+
.withMessage("Map keys must not contain a '/'");
73+
}
74+
6775
@Test
6876
void iterateReturnsAdaptedEntries() {
6977
TestNamedContributorsMapAdapter<String> adapter = createAdapter();

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

+14
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,20 @@ It's also possible to override the `show-details` and `roles` properties if requ
934934

935935
TIP: You can use `@Qualifier("groupname")` if you need to register custom `StatusAggregator` or `HttpCodeStatusMapper` beans for use with the group.
936936

937+
A health group can also include/exclude a `CompositeHealthContributor`.
938+
You can also include/exclude only a certain component of a `CompositeHealthContributor`.
939+
This can be done using the fully qualified name of the component as follows:
940+
941+
[source,properties,indent=0,subs="verbatim"]
942+
----
943+
management.endpoint.health.group.custom.include="test/primary"
944+
management.endpoint.health.group.custom.exclude="test/primary/b"
945+
----
946+
947+
In the example above, the `custom` group will include the `HealthContributor` with the name `primary` which is a component of the composite `test`.
948+
Here, `primary` itself is a composite and the `HealthContributor` with the name `b` will be excluded from the `custom` group.
949+
950+
937951
Health groups can be made available at an additional path on either the main or management port.
938952
This is useful in cloud environments such as Kubernetes, where it is quite common to use a separate management port for the actuator endpoints for security purposes.
939953
Having a separate port could lead to unreliable health checks because the main application might not work properly even if the health check is successful.

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/java/smoketest/actuator/SampleActuatorApplication.java

+26-1
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@
1616

1717
package smoketest.actuator;
1818

19+
import java.util.LinkedHashMap;
20+
import java.util.Map;
21+
1922
import org.springframework.boot.SpringApplication;
23+
import org.springframework.boot.actuate.health.CompositeHealthContributor;
2024
import org.springframework.boot.actuate.health.Health;
25+
import org.springframework.boot.actuate.health.HealthContributor;
2126
import org.springframework.boot.actuate.health.HealthIndicator;
2227
import org.springframework.boot.autoconfigure.SpringBootApplication;
2328
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@@ -33,7 +38,27 @@ public static void main(String[] args) {
3338

3439
@Bean
3540
public HealthIndicator helloHealthIndicator() {
36-
return () -> Health.up().withDetail("hello", "world").build();
41+
return createHealthIndicator("world");
42+
}
43+
44+
@Bean
45+
public HealthContributor compositeHelloHealthContributor() {
46+
Map<String, HealthContributor> map = new LinkedHashMap<>();
47+
map.put("spring", createNestedHealthContributor("spring"));
48+
map.put("boot", createNestedHealthContributor("boot"));
49+
return CompositeHealthContributor.fromMap(map);
50+
}
51+
52+
private HealthContributor createNestedHealthContributor(String name) {
53+
Map<String, HealthContributor> map = new LinkedHashMap<>();
54+
map.put("a", createHealthIndicator(name + "-a"));
55+
map.put("b", createHealthIndicator(name + "-b"));
56+
map.put("c", createHealthIndicator(name + "-c"));
57+
return CompositeHealthContributor.fromMap(map);
58+
}
59+
60+
private HealthIndicator createHealthIndicator(String value) {
61+
return () -> Health.up().withDetail("hello", value).build();
3762
}
3863

3964
}

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties

+4
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,8 @@ management.endpoint.health.show-details=always
2323
management.endpoint.health.group.ready.include=db,diskSpace
2424
management.endpoint.health.group.live.include=example,hello,db
2525
management.endpoint.health.group.live.show-details=never
26+
management.endpoint.health.group.comp.include=compositeHello/spring/a,compositeHello/spring/c
27+
management.endpoint.health.group.comp.show-details=always
28+
2629
management.endpoints.migrate-legacy-ids=true
30+

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/test/java/smoketest/actuator/AbstractManagementPortAndPathSampleActuatorApplicationTests.java

+10-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,16 @@ void testHealth() {
6767
ResponseEntity<String> entity = new TestRestTemplate().withBasicAuth("user", "password")
6868
.getForEntity("http://localhost:" + this.managementPort + "/admin/health", String.class);
6969
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
70-
assertThat(entity.getBody()).isEqualTo("{\"status\":\"UP\",\"groups\":[\"live\",\"ready\"]}");
70+
assertThat(entity.getBody()).isEqualTo("{\"status\":\"UP\",\"groups\":[\"comp\",\"live\",\"ready\"]}");
71+
}
72+
73+
@Test
74+
void testGroupWithComposite() {
75+
ResponseEntity<String> entity = new TestRestTemplate().withBasicAuth("user", "password")
76+
.getForEntity("http://localhost:" + this.managementPort + "/admin/health/comp", String.class);
77+
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
78+
assertThat(entity.getBody()).contains(
79+
"components\":{\"a\":{\"status\":\"UP\",\"details\":{\"hello\":\"spring-a\"}},\"c\":{\"status\":\"UP\",\"details\":{\"hello\":\"spring-c\"}}");
7180
}
7281

7382
@Test

0 commit comments

Comments
 (0)