Add actuator health check adapter

This commit adds the implementation to the previously added skeleton
ActuatorHealthAdapter. On a configurable schedule, the adapter takes
a configured list of Actuator health indicators and gets their current
status and maps it into the gRPC HealthStatusManager.

See #56

Signed-off-by: Chris Bono <chris.bono@gmail.com>
This commit is contained in:
Chris Bono
2024-11-24 23:03:05 -06:00
committed by Dave Syer
parent b7b43cb6a3
commit 6e154d53eb
11 changed files with 630 additions and 34 deletions

View File

@@ -53,6 +53,11 @@
<groupId>io.grpc</groupId>
<artifactId>grpc-services</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<!-- for testing unix domain sockets -->
<groupId>io.netty</groupId>
@@ -177,4 +182,4 @@
</pluginRepositories>
</project>
</project>

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.grpc.sample;
import java.time.Duration;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.grpc.client.GrpcChannelFactory;
import org.springframework.test.annotation.DirtiesContext;
import io.grpc.StatusRuntimeException;
import io.grpc.health.v1.HealthCheckRequest;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.health.v1.HealthGrpc;
import io.grpc.health.v1.HealthGrpc.HealthBlockingStub;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for gRPC server health feature.
*/
class GrpcServerHealthIntegrationTests {
@Nested
@SpringBootTest(properties = { "spring.grpc.server.health.actuator.health-indicator-paths=custom",
"spring.grpc.server.health.actuator.update-initial-delay=3s",
"spring.grpc.server.health.actuator.update-rate=3s", "management.health.defaults.enabled=true" })
@DirtiesContext
class WithActuatorHealthAdapter {
@Test
void healthIndicatorsAdaptedToGprcHealthStatus(@Autowired GrpcChannelFactory channels) {
var channel = channels.createChannel("0.0.0.0:0").build();
var healthStub = HealthGrpc.newBlockingStub(channel);
var serviceName = "custom";
// initially the status should be SERVING
assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.SERVING, Duration.ofSeconds(4));
// put the service down and the status should then be NOT_SERVING
CustomHealthIndicator.SERVICE_IS_UP = false;
assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.NOT_SERVING, Duration.ofSeconds(4));
// put the service up and the status should be SERVING
CustomHealthIndicator.SERVICE_IS_UP = true;
assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.SERVING, Duration.ofSeconds(4));
}
private void assertThatGrpcHealthStatusIs(HealthBlockingStub healthBlockingStub, String service,
ServingStatus expectedStatus, Duration maxWaitTime) {
Awaitility.await().atMost(maxWaitTime).ignoreException(StatusRuntimeException.class).untilAsserted(() -> {
var healthRequest = HealthCheckRequest.newBuilder().setService(service).build();
var healthResponse = healthBlockingStub.check(healthRequest);
assertThat(healthResponse.getStatus()).isEqualTo(expectedStatus);
var overallHealthRequest = HealthCheckRequest.newBuilder().setService("").build();
var overallHealthResponse = healthBlockingStub.check(overallHealthRequest);
assertThat(overallHealthResponse.getStatus()).isEqualTo(expectedStatus);
});
}
@TestConfiguration
static class MyHealthIndicatorsConfig {
@ConditionalOnEnabledHealthIndicator("custom")
@Bean
CustomHealthIndicator customHealthIndicator() {
return new CustomHealthIndicator();
}
}
static class CustomHealthIndicator implements HealthIndicator {
static boolean SERVICE_IS_UP = true;
@Override
public Health health() {
return SERVICE_IS_UP ? Health.up().build() : Health.down().build();
}
}
}
}

View File

@@ -18,8 +18,11 @@
|spring.grpc.client.default-channel.user-agent | |
|spring.grpc.server.address | | The address to bind to. could be a host:port combination or a pseudo URL like static://host:port. Can not be set if host or port are set independently.
|spring.grpc.server.exception-handling.enabled | `+++true+++` | Whether to enable user-defined global exception handling on the gRPC server.
|spring.grpc.server.health.actuator.enabled | `+++true+++` | Whether to adapt Actuator health checks into gRPC health checks.
|spring.grpc.server.health.actuator.endpoints | | List of Actuator health checks to adapt into gRPC health checks.
|spring.grpc.server.health.actuator.enabled | `+++true+++` | Whether to adapt Actuator health indicators into gRPC health checks.
|spring.grpc.server.health.actuator.health-indicator-paths | | List of Actuator health indicator paths to adapt into gRPC health checks.
|spring.grpc.server.health.actuator.update-initial-delay | `+++5s+++` | The initial delay before updating the health status the very first time.
|spring.grpc.server.health.actuator.update-overall-health | `+++true+++` | Whether to update the overall gRPC server health (the '' service) with the aggregate status of the configured health indicators.
|spring.grpc.server.health.actuator.update-rate | `+++5s+++` | How often to update the health status.
|spring.grpc.server.health.enabled | `+++true+++` | Whether to auto-configure Health feature on the gRPC server.
|spring.grpc.server.host | `+++*+++` | Server address to bind to. The default is any IP address ('*').
|spring.grpc.server.keep-alive.max-age | | Maximum time a connection may exist before being gracefully terminated (default infinite).

View File

@@ -169,14 +169,30 @@ public class GrpcServerProperties {
public static class ActuatorAdapt {
/**
* Whether to adapt Actuator health checks into gRPC health checks.
* Whether to adapt Actuator health indicators into gRPC health checks.
*/
private Boolean enabled = true;
/**
* List of Actuator health checks to adapt into gRPC health checks.
* Whether to update the overall gRPC server health (the '' service) with the
* aggregate status of the configured health indicators.
*/
private List<String> endpoints = new ArrayList<>();
private Boolean updateOverallHealth = true;
/**
* How often to update the health status.
*/
private Duration updateRate = Duration.ofSeconds(5);
/**
* The initial delay before updating the health status the very first time.
*/
private Duration updateInitialDelay = Duration.ofSeconds(5);
/**
* List of Actuator health indicator paths to adapt into gRPC health checks.
*/
private List<String> healthIndicatorPaths = new ArrayList<>();
public Boolean getEnabled() {
return this.enabled;
@@ -186,12 +202,36 @@ public class GrpcServerProperties {
this.enabled = enabled;
}
public List<String> getEndpoints() {
return this.endpoints;
public Boolean getUpdateOverallHealth() {
return this.updateOverallHealth;
}
public void setEndpoints(List<String> endpoints) {
this.endpoints = endpoints;
public void setUpdateOverallHealth(Boolean updateOverallHealth) {
this.updateOverallHealth = updateOverallHealth;
}
public Duration getUpdateRate() {
return this.updateRate;
}
public void setUpdateRate(Duration updateRate) {
this.updateRate = updateRate;
}
public Duration getUpdateInitialDelay() {
return this.updateInitialDelay;
}
public void setUpdateInitialDelay(Duration updateInitialDelay) {
this.updateInitialDelay = updateInitialDelay;
}
public List<String> getHealthIndicatorPaths() {
return this.healthIndicatorPaths;
}
public void setHealthIndicatorPaths(List<String> healthIndicatorPaths) {
this.healthIndicatorPaths = healthIndicatorPaths;
}
}

View File

@@ -16,13 +16,22 @@
package org.springframework.grpc.autoconfigure.server.health;
import org.springframework.boot.actuate.health.HealthContributor;
import org.springframework.boot.actuate.health.HealthEndpoint;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
import org.springframework.boot.actuate.health.StatusAggregator;
import org.springframework.core.log.LogAccessor;
import org.springframework.util.Assert;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.protobuf.services.HealthStatusManager;
/**
* Adapts {@link HealthContributor Actuator health checks} into gRPC health checks by
* Adapts {@link HealthIndicator Actuator health indicators} into gRPC health checks by
* periodically invoking {@link HealthEndpoint health endpoints} and updating the health
* status in gRPC {@link HealthStatusManager}.
*
@@ -30,4 +39,74 @@ import io.grpc.protobuf.services.HealthStatusManager;
*/
public class ActuatorHealthAdapter {
private static final String INVALID_INDICATOR_MSG = "Unable to determine health for '%s' - check that your configured health-indicator-paths point to available indicators";
private final LogAccessor logger = new LogAccessor(getClass());
private final HealthStatusManager healthStatusManager;
private final HealthEndpoint healthEndpoint;
private final StatusAggregator statusAggregator;
private final boolean updateOverallHealth;
private final List<String> healthIndicatorPaths;
protected ActuatorHealthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint,
StatusAggregator statusAggregator, boolean updateOverallHealth, List<String> healthIndicatorPaths) {
this.healthStatusManager = healthStatusManager;
this.healthEndpoint = healthEndpoint;
this.statusAggregator = statusAggregator;
this.updateOverallHealth = updateOverallHealth;
Assert.notEmpty(healthIndicatorPaths, () -> "at least one health indicator path is required");
this.healthIndicatorPaths = healthIndicatorPaths;
}
protected void updateHealthStatus() {
var individualStatuses = this.updateIndicatorsHealthStatus();
if (this.updateOverallHealth) {
this.updateOverallHealthStatus(individualStatuses);
}
}
protected Set<Status> updateIndicatorsHealthStatus() {
Set<Status> statuses = new HashSet<>();
this.healthIndicatorPaths.forEach((healthIndicatorPath) -> {
var healthComponent = this.healthEndpoint.healthForPath(healthIndicatorPath.split("/"));
if (healthComponent == null) {
this.logger.warn(() -> INVALID_INDICATOR_MSG.formatted(healthIndicatorPath));
}
else {
this.logger.trace(() -> "Actuator returned '%s' for indicator '%s'".formatted(healthComponent,
healthIndicatorPath));
var actuatorStatus = healthComponent.getStatus();
var grpcStatus = toServingStatus(actuatorStatus.getCode());
this.healthStatusManager.setStatus(healthIndicatorPath, grpcStatus);
this.logger.trace(() -> "Updated gRPC health status to '%s' for service '%s'".formatted(grpcStatus,
healthIndicatorPath));
statuses.add(actuatorStatus);
}
});
return statuses;
}
protected void updateOverallHealthStatus(Set<Status> individualStatuses) {
var overallActuatorStatus = this.statusAggregator.getAggregateStatus(individualStatuses);
var overallGrpcStatus = toServingStatus(overallActuatorStatus.getCode());
this.logger.trace(() -> "Actuator aggregate status '%s' for overall health".formatted(overallActuatorStatus));
this.healthStatusManager.setStatus("", overallGrpcStatus);
this.logger.trace(() -> "Updated overall gRPC health status to '%s'".formatted(overallGrpcStatus));
}
protected ServingStatus toServingStatus(String actuatorHealthStatusCode) {
return switch (actuatorHealthStatusCode) {
case "UP" -> ServingStatus.SERVING;
case "DOWN" -> ServingStatus.NOT_SERVING;
case "OUT_OF_SERVICE" -> ServingStatus.NOT_SERVING;
case "UNKNOWN" -> ServingStatus.UNKNOWN;
default -> ServingStatus.UNKNOWN;
};
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.grpc.autoconfigure.server.health;
import java.time.Duration;
import java.time.Instant;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
/**
* Periodically invokes the {@link ActuatorHealthAdapter} in the background.
*
* @author Chris Bono
*/
class ActuatorHealthAdapterInvoker implements InitializingBean, DisposableBean {
private final ActuatorHealthAdapter healthAdapter;
private final SimpleAsyncTaskScheduler taskScheduler;
private final Duration updateInitialDelay;
private final Duration updateFixedRate;
ActuatorHealthAdapterInvoker(ActuatorHealthAdapter healthAdapter, SimpleAsyncTaskSchedulerBuilder schedulerBuilder,
Duration updateInitialDelay, Duration updateFixedRate) {
this.healthAdapter = healthAdapter;
this.taskScheduler = schedulerBuilder.threadNamePrefix("healthAdapter-").build();
this.updateInitialDelay = updateInitialDelay;
this.updateFixedRate = updateFixedRate;
}
@Override
public void afterPropertiesSet() {
this.taskScheduler.scheduleAtFixedRate(this::updateHealthStatus, Instant.now().plus(this.updateInitialDelay),
this.updateFixedRate);
}
@Override
public void destroy() {
this.taskScheduler.close();
}
void updateHealthStatus() {
this.healthAdapter.updateHealthStatus();
}
}

View File

@@ -18,19 +18,34 @@
package org.springframework.grpc.autoconfigure.server.health;
import java.util.List;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.StatusAggregator;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionMessage;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.type.AnnotatedTypeMetadata;
import org.springframework.grpc.autoconfigure.server.GrpcServerFactoryAutoConfiguration;
import org.springframework.grpc.autoconfigure.server.GrpcServerProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
import io.grpc.BindableService;
import io.grpc.protobuf.services.HealthStatusManager;
@@ -58,18 +73,56 @@ public class GrpcServerHealthAutoConfiguration {
}
@Configuration(proxyBeanMethods = false)
@AutoConfigureAfter(name = "org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration")
@ConditionalOnClass(HealthEndpoint.class)
@ConditionalOnBean(HealthEndpoint.class)
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class)
@AutoConfigureAfter(value = TaskSchedulingAutoConfiguration.class,
name = "org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration")
@ConditionalOnProperty(name = "spring.grpc.server.health.actuator.enabled", havingValue = "true",
matchIfMissing = true)
@Conditional(OnHealthIndicatorPathsCondition.class)
@EnableConfigurationProperties(GrpcServerProperties.class)
@EnableScheduling
static class ActuatorHealthAdapterConfiguration {
@Bean
@ConditionalOnMissingBean
ActuatorHealthAdapter healthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint,
GrpcServerProperties serverProperties) {
return new ActuatorHealthAdapter();
StatusAggregator statusAggregator, GrpcServerProperties serverProperties) {
return new ActuatorHealthAdapter(healthStatusManager, healthEndpoint, statusAggregator,
serverProperties.getHealth().getActuator().getUpdateOverallHealth(),
serverProperties.getHealth().getActuator().getHealthIndicatorPaths());
}
@Bean
ActuatorHealthAdapterInvoker healthAdapterInvoker(ActuatorHealthAdapter healthAdapter,
SimpleAsyncTaskSchedulerBuilder schedulerBuilder, GrpcServerProperties serverProperties) {
return new ActuatorHealthAdapterInvoker(healthAdapter, schedulerBuilder,
serverProperties.getHealth().getActuator().getUpdateInitialDelay(),
serverProperties.getHealth().getActuator().getUpdateRate());
}
}
/**
* Condition to determine if
* {@code spring.grpc.server.health.actuator.health-indicator-paths} is specified with
* at least one entry.
*/
static class OnHealthIndicatorPathsCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
String propertyName = "spring.grpc.server.health.actuator.health-indicator-paths";
BindResult<List<String>> property = Binder.get(context.getEnvironment())
.bind(propertyName, Bindable.listOf(String.class));
ConditionMessage.Builder messageBuilder = ConditionMessage
.forCondition("Health indicator paths (at least one)");
if (property.isBound() && !property.get().isEmpty()) {
return ConditionOutcome
.match(messageBuilder.because("property %s found with at least one entry".formatted(propertyName)));
}
return ConditionOutcome.noMatch(
messageBuilder.because("property %s not found with at least one entry".formatted(propertyName)));
}
}

View File

@@ -71,7 +71,10 @@ class GrpcServerPropertiesTests {
GrpcServerProperties.Health properties = bindProperties(map).getHealth();
assertThat(properties.getEnabled()).isTrue();
assertThat(properties.getActuator().getEnabled()).isTrue();
assertThat(properties.getActuator().getEndpoints()).isEmpty();
assertThat(properties.getActuator().getHealthIndicatorPaths()).isEmpty();
assertThat(properties.getActuator().getUpdateOverallHealth()).isTrue();
assertThat(properties.getActuator().getUpdateRate()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.getActuator().getUpdateInitialDelay()).isEqualTo(Duration.ofSeconds(5));
}
@Test
@@ -79,11 +82,17 @@ class GrpcServerPropertiesTests {
Map<String, String> map = new HashMap<>();
map.put("spring.grpc.server.health.enabled", "false");
map.put("spring.grpc.server.health.actuator.enabled", "false");
map.put("spring.grpc.server.health.actuator.endpoints", "a,b,c");
map.put("spring.grpc.server.health.actuator.health-indicator-paths", "a,b,c");
map.put("spring.grpc.server.health.actuator.update-overall-health", "false");
map.put("spring.grpc.server.health.actuator.update-rate", "2s");
map.put("spring.grpc.server.health.actuator.update-initial-delay", "1m");
GrpcServerProperties.Health properties = bindProperties(map).getHealth();
assertThat(properties.getEnabled()).isFalse();
assertThat(properties.getActuator().getEnabled()).isFalse();
assertThat(properties.getActuator().getEndpoints()).containsExactly("a", "b", "c");
assertThat(properties.getActuator().getHealthIndicatorPaths()).containsExactly("a", "b", "c");
assertThat(properties.getActuator().getUpdateOverallHealth()).isFalse();
assertThat(properties.getActuator().getUpdateRate()).isEqualTo(Duration.ofSeconds(2));
assertThat(properties.getActuator().getUpdateInitialDelay()).isEqualTo(Duration.ofMinutes(1));
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.grpc.autoconfigure.server.health;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.time.Duration;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
/**
* Tests for {@link ActuatorHealthAdapterInvoker}.
*/
class ActuatorHealthAdapterInvokerTests {
@Test
void healthAdapterInvokedOnSchedule() {
ActuatorHealthAdapter healthAdapter = mock();
ActuatorHealthAdapterInvoker invoker = new ActuatorHealthAdapterInvoker(healthAdapter,
new SimpleAsyncTaskSchedulerBuilder(), Duration.ofSeconds(5), Duration.ofSeconds(3));
try {
invoker.afterPropertiesSet();
Awaitility.await()
.between(Duration.ofSeconds(8), Duration.ofSeconds(10))
.untilAsserted(() -> verify(healthAdapter, times(2)).updateHealthStatus());
}
finally {
invoker.destroy();
}
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.grpc.autoconfigure.server.health;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.health.Status;
import org.springframework.boot.actuate.health.StatusAggregator;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.protobuf.services.HealthStatusManager;
/**
* Tests for {@link ActuatorHealthAdapter}.
*/
class ActuatorHealthAdapterTests {
private HealthStatusManager mockHealthStatusManager;
private HealthEndpoint mockHealthEndpoint;
private StatusAggregator mockStatusAggregator;
@BeforeEach
void prepareMocks() {
mockHealthStatusManager = Mockito.mock();
mockHealthEndpoint = Mockito.mock();
mockStatusAggregator = Mockito.mock();
}
@Test
void whenIndicatorPathsFoundStatusIsUpdated() {
var service1 = "check1";
var service2 = "component2/check2";
var service3 = "component3a/component3b/check3";
when(mockHealthEndpoint.healthForPath("check1")).thenReturn(Health.up().build());
when(mockHealthEndpoint.healthForPath("component2", "check2")).thenReturn(Health.down().build());
when(mockHealthEndpoint.healthForPath("component3a", "component3b", "check3"))
.thenReturn(Health.unknown().build());
when(mockStatusAggregator.getAggregateStatus(anySet())).thenReturn(Status.UNKNOWN);
var healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint, mockStatusAggregator,
true, List.of(service1, service2, service3));
healthAdapter.updateHealthStatus();
verify(mockHealthStatusManager).setStatus(service1, ServingStatus.SERVING);
verify(mockHealthStatusManager).setStatus(service2, ServingStatus.NOT_SERVING);
verify(mockHealthStatusManager).setStatus(service3, ServingStatus.UNKNOWN);
ArgumentCaptor<Set<Status>> statusesArgCaptor = ArgumentCaptor.captor();
verify(mockStatusAggregator).getAggregateStatus(statusesArgCaptor.capture());
assertThat(statusesArgCaptor.getValue())
.containsExactlyInAnyOrderElementsOf(Set.of(Status.UP, Status.DOWN, Status.UNKNOWN));
verify(mockHealthStatusManager).setStatus("", ServingStatus.UNKNOWN);
}
@Test
void whenOverallHealthIsFalseOverallStatusIsNotUpdated() {
var service1 = "check1";
when(mockHealthEndpoint.healthForPath("check1")).thenReturn(Health.up().build());
var healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint, mockStatusAggregator,
false, List.of(service1));
healthAdapter.updateHealthStatus();
verifyNoInteractions(mockStatusAggregator);
verify(mockHealthStatusManager, never()).setStatus(eq(""), any(ServingStatus.class));
}
@Test
void whenIndicatorPathNotFoundStatusIsNotUpdated() {
var healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint, mockStatusAggregator,
false, List.of("check1"));
healthAdapter.updateHealthStatus();
verifyNoInteractions(mockHealthStatusManager);
}
@Test
void whenNoIndicatorPathsSpecifiedThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint,
mockStatusAggregator, false, List.of()))
.withMessage("at least one health indicator path is required");
}
@Nested
class ToServingStatusApi {
private final ActuatorHealthAdapter healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager,
mockHealthEndpoint, mockStatusAggregator, false, List.of("check1"));
@Test
void whenActuatorStatusIsUpThenServingStatusIsUp() {
assertThat(this.healthAdapter.toServingStatus(Status.UP.getCode())).isEqualTo(ServingStatus.SERVING);
}
@Test
void whenActuatorStatusIsUnknownThenServingStatusIsUnknown() {
assertThat(this.healthAdapter.toServingStatus(Status.UNKNOWN.getCode())).isEqualTo(ServingStatus.UNKNOWN);
}
@Test
void whenActuatorStatusIsDownThenServingStatusIsNotServing() {
assertThat(this.healthAdapter.toServingStatus(Status.DOWN.getCode())).isEqualTo(ServingStatus.NOT_SERVING);
}
@Test
void whenActuatorStatusIsOutOfServiceThenServingStatusIsNotServing() {
assertThat(this.healthAdapter.toServingStatus(Status.OUT_OF_SERVICE.getCode()))
.isEqualTo(ServingStatus.NOT_SERVING);
}
}
}

View File

@@ -30,6 +30,7 @@ import org.mockito.Mockito;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -142,41 +143,55 @@ class GrpcServerHealthAutoConfigurationTests {
@Nested
class ActuatorHealthAdapterConfigurationTests {
private ApplicationContextRunner validContextRunner() {
return GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withPropertyValues("spring.grpc.server.health.actuator.health-indicator-paths=my-indicator")
.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class,
TaskSchedulingAutoConfiguration.class));
}
@Test
void adapterIsAutoConfiguredAfterHealthAutoConfiguration() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
this.validContextRunner()
.run((context) -> assertThatBeanDefinitionsContainInOrder(context,
HealthEndpointAutoConfiguration.class, ActuatorHealthAdapterConfiguration.class));
}
@Test
void adapterIsAutoConfiguredAfterTaskSchedulingAutoConfiguration() {
this.validContextRunner()
.run((context) -> assertThatBeanDefinitionsContainInOrder(context,
TaskSchedulingAutoConfiguration.class, ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenHealthEndpointNotOnClasspathAutoConfigurationIsSkipped() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class))
.withClassLoader(new FilteredClassLoader(HealthEndpoint.class))
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenHealthEndpointBeanNotAvailableAutoConfigurationIsSkipped() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
void whenHealthEndpointNotAvailableAutoConfigurationIsSkipped() {
this.validContextRunner()
.withPropertyValues("management.endpoint.health.enabled=false")
.withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class))
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenActuatorPropertyNotSetAdapterIsAutoConfigured() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
this.validContextRunner()
.run((context) -> assertThat(context)
.hasSingleBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenActuatorPropertyIsTrueAdapterIsAutoConfigured() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
this.validContextRunner()
.withPropertyValues("spring.grpc.server.health.actuator.enabled=true")
.run((context) -> assertThat(context)
.hasSingleBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
@@ -184,18 +199,44 @@ class GrpcServerHealthAutoConfigurationTests {
@Test
void whenActuatorPropertyIsFalseAdapterIsNotAutoConfigured() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
this.validContextRunner()
.withPropertyValues("spring.grpc.server.health.actuator.enabled=false")
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void adapterAutoConfiguredAsExpected() {
void whenHealthIndicatorPathsIsNotSpecifiedAdapterIsNotAutoConfigured() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
.run((context) -> assertThat(context).hasSingleBean(ActuatorHealthAdapter.class));
.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class,
TaskSchedulingAutoConfiguration.class))
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenHealthIndicatorPathsIsSpecifiedEmptyAdapterIsNotAutoConfigured() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
.withPropertyValues("spring.grpc.server.health.actuator.health-indicator-paths=")
.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class,
TaskSchedulingAutoConfiguration.class))
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenHasUserDefinedAdapterDoesNotAutoConfigureBean() {
ActuatorHealthAdapter customAdapter = mock();
this.validContextRunner()
.withBean("customAdapter", ActuatorHealthAdapter.class, () -> customAdapter)
.run((context) -> assertThat(context).getBean(ActuatorHealthAdapter.class).isSameAs(customAdapter));
}
@Test
void adapterAutoConfiguredAsExpected() {
this.validContextRunner()
.run((context) -> assertThat(context).hasSingleBean(ActuatorHealthAdapter.class)
.hasSingleBean(ActuatorHealthAdapterInvoker.class));
}
}