From e8e5808aa984a686cf5922abe05e3c906eacd1cc Mon Sep 17 00:00:00 2001 From: Miquel Simon Date: Wed, 21 Oct 2020 10:10:50 +0200 Subject: [PATCH] KEYCLOAK-13639. Added metrics and custom healthcheck endpoints, both enabled via 'metrics.enabled' config parameter. --- quarkus/deployment/pom.xml | 8 ++ .../quarkus/deployment/KeycloakProcessor.java | 9 ++- quarkus/runtime/pom.xml | 8 ++ .../keycloak/configuration/Configuration.java | 10 +++ .../configuration/PropertyMapper.java | 4 + .../configuration/PropertyMappers.java | 5 ++ .../quarkus/QuarkusRequestFilter.java | 14 +++- .../keycloak/quarkus/KeycloakRecorder.java | 22 ++++++ .../health/KeycloakReadyHealthCheck.java | 79 +++++++++++++++++++ .../resources/META-INF/keycloak.properties | 5 +- .../src/main/resources/application.properties | 6 +- .../transaction/JtaTransactionWrapper.java | 8 ++ 12 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 quarkus/runtime/src/main/java/org/keycloak/services/health/KeycloakReadyHealthCheck.java diff --git a/quarkus/deployment/pom.xml b/quarkus/deployment/pom.xml index c2a2a30ab37..b5da3bcfbee 100644 --- a/quarkus/deployment/pom.xml +++ b/quarkus/deployment/pom.xml @@ -66,6 +66,14 @@ io.quarkus quarkus-bootstrap-core + + io.quarkus + quarkus-smallrye-health-deployment + + + io.quarkus + quarkus-smallrye-metrics-deployment + io.quarkus quarkus-junit5-internal diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index d19764df8f0..c2868cc5cb0 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -46,6 +46,7 @@ import org.keycloak.common.Profile; import org.keycloak.config.ConfigProviderFactory; import org.keycloak.configuration.Configuration; import org.keycloak.configuration.KeycloakConfigSourceProvider; +import org.keycloak.configuration.MicroProfileConfigProvider; import org.keycloak.connections.jpa.DefaultJpaConnectionProviderFactory; import org.keycloak.connections.jpa.updater.liquibase.LiquibaseJpaUpdaterProviderFactory; import org.keycloak.connections.jpa.updater.liquibase.conn.DefaultLiquibaseConnectionProvider; @@ -194,9 +195,13 @@ class KeycloakProcessor { indexDependencyBuildItemBuildProducer.produce(new IndexDependencyBuildItem("org.keycloak", "keycloak-services")); } + @Record(ExecutionTime.RUNTIME_INIT) @BuildStep - void initializeRouter(BuildProducer routes) { - routes.produce(new FilterBuildItem(new QuarkusRequestFilter(), FilterBuildItem.AUTHORIZATION - 10)); + void initializeFilter(BuildProducer routes, KeycloakRecorder recorder) { + Optional metricsEnabled = Configuration.getOptionalBooleanValue(MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX.concat("metrics.enabled")); + + routes.produce(new FilterBuildItem(recorder.createFilter(metricsEnabled.orElse(false)), + FilterBuildItem.AUTHORIZATION - 10)); } @BuildStep(onlyIf = IsDevelopment.class) diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml index 8bf065c0596..e4199d35ae6 100644 --- a/quarkus/runtime/pom.xml +++ b/quarkus/runtime/pom.xml @@ -63,6 +63,14 @@ io.quarkus quarkus-core + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-smallrye-metrics + diff --git a/quarkus/runtime/src/main/java/org/keycloak/configuration/Configuration.java b/quarkus/runtime/src/main/java/org/keycloak/configuration/Configuration.java index 6e687ac3f98..1f2fd414fd8 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/configuration/Configuration.java +++ b/quarkus/runtime/src/main/java/org/keycloak/configuration/Configuration.java @@ -18,6 +18,7 @@ package org.keycloak.configuration; import java.util.Optional; +import java.util.function.Function; import io.smallrye.config.ConfigValue; import io.smallrye.config.SmallRyeConfig; @@ -69,4 +70,13 @@ public final class Configuration { public static Optional getOptionalValue(String name) { return getConfig().getOptionalValue(name, String.class); } + + public static Optional getOptionalBooleanValue(String name) { + return getConfig().getOptionalValue(name, String.class).map(new Function() { + @Override + public Boolean apply(String s) { + return Boolean.parseBoolean(s); + } + }); + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMapper.java b/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMapper.java index 97a1b95aa65..318574d3e16 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMapper.java +++ b/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMapper.java @@ -60,6 +60,10 @@ public class PropertyMapper { return MAPPERS.computeIfAbsent(toProperty, s -> new PropertyMapper(fromProperty, s, null, transformer, null, true, description, false)); } + static PropertyMapper createBuildTimeProperty(String fromProperty, String toProperty, String description) { + return MAPPERS.computeIfAbsent(toProperty, s -> new PropertyMapper(fromProperty, s, null, null, null, true, description, false)); + } + static Map MAPPERS = new HashMap<>(); static PropertyMapper IDENTITY = new PropertyMapper(null, null, null, null, null) { diff --git a/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMappers.java index bce4d4d80fd..cee6b706d4b 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/configuration/PropertyMappers.java @@ -46,6 +46,7 @@ public final class PropertyMappers { configureProxyMappers(); configureClustering(); configureHostnameProviderMappers(); + configureMetrics(); } private static void configureHttpPropertyMappers() { @@ -150,6 +151,10 @@ public final class PropertyMappers { create("hostname-force-backend-url-to-frontend-url ", "kc.spi.hostname.default.force-backend-url-to-frontend-url", "Forces backend requests to go through the URL defined as the frontend-url. Defaults to false. Possible values are true or false."); } + private static void configureMetrics() { + createBuildTimeProperty("metrics.enabled", "quarkus.datasource.metrics.enabled", "If the server should expose metrics and healthcheck. If enabled, metrics are available at the '/metrics' endpoint and healthcheck at the '/health' endpoint."); + } + static ConfigValue getValue(ConfigSourceInterceptorContext context, String name) { return PropertyMapper.MAPPERS.getOrDefault(name, PropertyMapper.IDENTITY) .getOrDefault(name, context, context.proceed(name)); diff --git a/quarkus/runtime/src/main/java/org/keycloak/provider/quarkus/QuarkusRequestFilter.java b/quarkus/runtime/src/main/java/org/keycloak/provider/quarkus/QuarkusRequestFilter.java index 8d00662a825..28a4996c170 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/provider/quarkus/QuarkusRequestFilter.java +++ b/quarkus/runtime/src/main/java/org/keycloak/provider/quarkus/QuarkusRequestFilter.java @@ -17,8 +17,9 @@ package org.keycloak.provider.quarkus; +import java.util.function.Predicate; + import org.keycloak.common.ClientConnection; -import org.keycloak.models.KeycloakSession; import org.keycloak.services.filters.AbstractRequestFilter; import io.vertx.core.AsyncResult; @@ -39,8 +40,15 @@ public class QuarkusRequestFilter extends AbstractRequestFilter implements Handl // we don't really care about the result because any exception thrown should be handled by the parent class }; + private Predicate enabledEndpoints; + @Override public void handle(RoutingContext context) { + if (!enabledEndpoints.test(context)) { + context.fail(404); + return; + } + // our code should always be run as blocking until we don't provide a better support for running non-blocking code // in the event loop context.vertx().executeBlocking(promise -> { @@ -94,4 +102,8 @@ public class QuarkusRequestFilter extends AbstractRequestFilter implements Handl } }; } + + public void setEnabledEndpoints(Predicate disabledEndpoints) { + this.enabledEndpoints = disabledEndpoints; + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/KeycloakRecorder.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/KeycloakRecorder.java index 9d59b115013..211aad1272a 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/KeycloakRecorder.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/KeycloakRecorder.java @@ -26,6 +26,8 @@ import java.util.function.Predicate; import java.util.stream.StreamSupport; import io.smallrye.config.ConfigValue; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; import org.jboss.logging.Logger; import org.keycloak.QuarkusKeycloakSessionFactory; import org.keycloak.cli.ShowConfigCommand; @@ -43,6 +45,7 @@ import org.keycloak.provider.Spi; import io.quarkus.runtime.annotations.Recorder; import liquibase.logging.LogFactory; import liquibase.servicelocator.ServiceLocator; +import org.keycloak.provider.quarkus.QuarkusRequestFilter; import org.keycloak.util.Environment; @Recorder @@ -210,4 +213,23 @@ public class KeycloakRecorder { } }); } + + public Handler createFilter(boolean metricsEnabled) { + QuarkusRequestFilter handler = new QuarkusRequestFilter(); + + handler.setEnabledEndpoints(new Predicate() { + @Override + public boolean test(RoutingContext context) { + + if (context.request().uri().startsWith("/metrics") || + context.request().uri().startsWith("/health")) { + return metricsEnabled; + } + + return true; + } + }); + + return handler; + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/services/health/KeycloakReadyHealthCheck.java b/quarkus/runtime/src/main/java/org/keycloak/services/health/KeycloakReadyHealthCheck.java new file mode 100644 index 00000000000..0574fc2a950 --- /dev/null +++ b/quarkus/runtime/src/main/java/org/keycloak/services/health/KeycloakReadyHealthCheck.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 + * + * http://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.keycloak.services.health; + +import io.agroal.api.AgroalDataSource; +import io.quarkus.agroal.runtime.health.DataSourceHealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Readiness; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Keycloak Healthcheck Readiness Probe. + * + * Performs a hybrid between the passive and the active mode. If there are no healthy connections in the pool, + * it invokes the standard DataSourceHealthCheck that creates a new connection and checks if its valid. + * + * @see Healthcheck API Design + */ +@Readiness +@ApplicationScoped +public class KeycloakReadyHealthCheck extends DataSourceHealthCheck { + + /** + * Date formatter, the same as used by Quarkus. This enables users to quickly compare the date printed + * by the probe with the logs. + */ + static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS").withZone(ZoneId.systemDefault()); + + @Inject + AgroalDataSource agroalDataSource; + + AtomicReference failingSince = new AtomicReference<>(); + + @Override + public HealthCheckResponse call() { + HealthCheckResponseBuilder builder = HealthCheckResponse.named("Keycloak database connections health check").up(); + long activeCount = agroalDataSource.getMetrics().activeCount(); + long invalidCount = agroalDataSource.getMetrics().invalidCount(); + if (activeCount < 1 || invalidCount > 0) { + HealthCheckResponse activeCheckResult = super.call(); + if (activeCheckResult.getState() == HealthCheckResponse.State.DOWN) { + builder.down(); + Instant failingTime = failingSince.updateAndGet(this::createInstanceIfNeeded); + builder.withData("Failing since", DATE_FORMATTER.format(failingTime)); + } + } else { + failingSince.set(null); + } + return builder.build(); + } + + Instant createInstanceIfNeeded(Instant instant) { + if (instant == null) { + return Instant.now(); + } + return instant; + } +} \ No newline at end of file diff --git a/quarkus/server/src/main/resources/META-INF/keycloak.properties b/quarkus/server/src/main/resources/META-INF/keycloak.properties index 1e9124c0654..1e7b651708b 100644 --- a/quarkus/server/src/main/resources/META-INF/keycloak.properties +++ b/quarkus/server/src/main/resources/META-INF/keycloak.properties @@ -1,4 +1,4 @@ -# Default and non-production grade database vendor +# Default and non-production grade database vendor db=h2-file # Default, and insecure, and non-production grade configuration for the development profile @@ -7,6 +7,9 @@ db=h2-file %dev.db.password = keycloak %dev.cluster=local +# Metrics and healthcheck are disabled by default +metrics.enabled=false + # Logging configuration. INFO is the default level for most of the categories #quarkus.log.level = DEBUG quarkus.log.category."org.jboss.resteasy.resteasy_jaxrs.i18n".level=WARN diff --git a/quarkus/server/src/main/resources/application.properties b/quarkus/server/src/main/resources/application.properties index 48d3cddc77a..0e59493fae7 100644 --- a/quarkus/server/src/main/resources/application.properties +++ b/quarkus/server/src/main/resources/application.properties @@ -7,4 +7,8 @@ quarkus.package.main-class=keycloak quarkus.http.root-path=/ quarkus.application.name=Keycloak -quarkus.banner.enabled=false \ No newline at end of file +quarkus.banner.enabled=false + +# Disable the default data source health check by Agroal extension, since we provide our own (default is true) +quarkus.datasource.health.enabled=false + diff --git a/services/src/main/java/org/keycloak/transaction/JtaTransactionWrapper.java b/services/src/main/java/org/keycloak/transaction/JtaTransactionWrapper.java index e3382b27f5b..197cd285e38 100644 --- a/services/src/main/java/org/keycloak/transaction/JtaTransactionWrapper.java +++ b/services/src/main/java/org/keycloak/transaction/JtaTransactionWrapper.java @@ -88,6 +88,10 @@ public class JtaTransactionWrapper implements KeycloakTransaction { @Override public void commit() { try { + if (Status.STATUS_NO_TRANSACTION == tm.getStatus() || + Status.STATUS_ACTIVE != tm.getStatus()) { + return; + } logger.debug("JtaTransactionWrapper commit"); tm.commit(); } catch (Exception e) { @@ -100,6 +104,10 @@ public class JtaTransactionWrapper implements KeycloakTransaction { @Override public void rollback() { try { + if (Status.STATUS_NO_TRANSACTION == tm.getStatus() || + Status.STATUS_ACTIVE != tm.getStatus()) { + return; + } logger.debug("JtaTransactionWrapper rollback"); tm.rollback(); } catch (Exception e) {