From 6e154d53eb278561b1a42b21b287927f80a548dc Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Sun, 24 Nov 2024 23:03:05 -0600 Subject: [PATCH] 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 --- samples/grpc-server/pom.xml | 7 +- .../GrpcServerHealthIntegrationTests.java | 108 +++++++++++++ .../modules/ROOT/partials/_configprops.adoc | 7 +- .../server/GrpcServerProperties.java | 54 ++++++- .../server/health/ActuatorHealthAdapter.java | 85 ++++++++++- .../health/ActuatorHealthAdapterInvoker.java | 65 ++++++++ .../GrpcServerHealthAutoConfiguration.java | 63 +++++++- .../server/GrpcServerPropertiesTests.java | 15 +- .../ActuatorHealthAdapterInvokerTests.java | 51 +++++++ .../health/ActuatorHealthAdapterTests.java | 142 ++++++++++++++++++ ...rpcServerHealthAutoConfigurationTests.java | 67 +++++++-- 11 files changed, 630 insertions(+), 34 deletions(-) create mode 100644 samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java create mode 100644 spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java create mode 100644 spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java create mode 100644 spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java diff --git a/samples/grpc-server/pom.xml b/samples/grpc-server/pom.xml index 5d66be6..7f11aaf 100644 --- a/samples/grpc-server/pom.xml +++ b/samples/grpc-server/pom.xml @@ -53,6 +53,11 @@ io.grpc grpc-services + + org.springframework.boot + spring-boot-starter-actuator + + io.netty @@ -177,4 +182,4 @@ - \ No newline at end of file + diff --git a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java new file mode 100644 index 0000000..d1d97cc --- /dev/null +++ b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java @@ -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(); + } + + } + + } + +} diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc index 1b1eb8d..cfb36b6 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc @@ -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). diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java index fb28013..91842f5 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java @@ -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 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 healthIndicatorPaths = new ArrayList<>(); public Boolean getEnabled() { return this.enabled; @@ -186,12 +202,36 @@ public class GrpcServerProperties { this.enabled = enabled; } - public List getEndpoints() { - return this.endpoints; + public Boolean getUpdateOverallHealth() { + return this.updateOverallHealth; } - public void setEndpoints(List 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 getHealthIndicatorPaths() { + return this.healthIndicatorPaths; + } + + public void setHealthIndicatorPaths(List healthIndicatorPaths) { + this.healthIndicatorPaths = healthIndicatorPaths; } } diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java index 3757a10..4de6558 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java @@ -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 healthIndicatorPaths; + + protected ActuatorHealthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint, + StatusAggregator statusAggregator, boolean updateOverallHealth, List 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 updateIndicatorsHealthStatus() { + Set 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 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; + }; + } + } diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java new file mode 100644 index 0000000..4bbfdf7 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java @@ -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(); + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java index 99dd5f5..f9e9dfb 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java @@ -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> 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))); } } diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java index 7cd4540..83bd9a9 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java @@ -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 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)); } } diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java new file mode 100644 index 0000000..1c1dfc1 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java @@ -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(); + } + + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java new file mode 100644 index 0000000..b7da32b --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java @@ -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> 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); + } + + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java index e575251..71f01ea 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java @@ -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)); } }