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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user