From dc1c4eb6a1a5e88f4f41cff8613cdfa4cf8ad3aa Mon Sep 17 00:00:00 2001 From: Michele Albanese Date: Tue, 7 May 2024 10:27:04 +0200 Subject: [PATCH] EPA-105: Expose metrics on a separate port (#73) --- .../ehealthid/relyingparty/ConfigReader.java | 12 +++-- .../oviva/ehealthid/relyingparty/Main.java | 34 +++++++++++-- .../oviva/ehealthid/relyingparty/ws/App.java | 8 +--- .../relyingparty/ws/HealthEndpoint.java | 32 ++++++++----- .../relyingparty/ws/MetricsEndpoint.java | 30 ++++++++---- .../ehealthid/relyingparty/MainTest.java | 12 +++-- .../test/EmbeddedRelyingParty.java | 5 ++ .../relyingparty/ws/HealthEndpointTest.java | 37 ++++++++++++-- .../relyingparty/ws/MetricsEndpointTest.java | 48 ++++++++++++++++++- 9 files changed, 172 insertions(+), 46 deletions(-) diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java index 42c4040..4b3846d 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java @@ -19,6 +19,7 @@ public class ConfigReader { public static final String CONFIG_BASE_URI = "base_uri"; public static final String CONFIG_HOST = "host"; public static final String CONFIG_PORT = "port"; + public static final String CONFIG_MANAGEMENT_PORT = "management_port"; public static final String CONFIG_REDIRECT_URIS = "redirect_uris"; public static final String CONFIG_IDP_DISCOVERY_URI = "idp_discovery_uri"; @@ -60,7 +61,8 @@ public Config read() { "no '%s' configured".formatted(CONFIG_IDP_DISCOVERY_URI))); var host = configProvider.get(CONFIG_HOST).orElse("0.0.0.0"); - var port = getPortConfig(); + var port = getPortConfig(CONFIG_PORT, 1234); + var managementPort = getPortConfig(CONFIG_MANAGEMENT_PORT, 1235); var fedmaster = configProvider @@ -104,6 +106,7 @@ public Config read() { federationConfig, host, port, + managementPort, baseUri, idpDiscoveryUri, sessionStoreConfig(), @@ -150,11 +153,11 @@ private List getScopes() { .toList(); } - private int getPortConfig() { - return configProvider.get(CONFIG_PORT).stream() + private int getPortConfig(String configPort, int defaultValue) { + return configProvider.get(configPort).stream() .mapToInt(Integer::parseInt) .findFirst() - .orElse(1234); + .orElse(defaultValue); } private JWKSet loadJwks(String configName) { @@ -176,6 +179,7 @@ public record Config( FederationConfig federation, String host, int port, + int managementPort, URI baseUri, URI idpDiscoveryUri, SessionStoreConfig sessionStore, diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java index 5352352..53f8208 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java @@ -29,12 +29,17 @@ import com.oviva.ehealthid.relyingparty.svc.TokenIssuerImpl; import com.oviva.ehealthid.relyingparty.util.DiscoveryJwkSetSource; import com.oviva.ehealthid.relyingparty.ws.App; +import com.oviva.ehealthid.relyingparty.ws.HealthEndpoint; +import com.oviva.ehealthid.relyingparty.ws.MetricsEndpoint; import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; import io.micrometer.prometheus.PrometheusConfig; import io.micrometer.prometheus.PrometheusMeterRegistry; +import io.undertow.Handlers; +import io.undertow.Undertow; import jakarta.ws.rs.SeBootstrap; import jakarta.ws.rs.SeBootstrap.Configuration; -import jakarta.ws.rs.SeBootstrap.Instance; +import jakarta.ws.rs.core.UriBuilder; +import java.net.InetSocketAddress; import java.net.URI; import java.net.http.HttpClient; import java.time.Clock; @@ -59,7 +64,8 @@ public class Main implements AutoCloseable { private static final String CONFIG_PREFIX = "EHEALTHID_RP"; private final ConfigProvider configProvider; - private Instance server; + private SeBootstrap.Instance server; + private Undertow managementServer; private CountDownLatch shutdown = new CountDownLatch(1); @@ -90,6 +96,13 @@ public URI baseUri() { return server.configuration().baseUri(); } + public URI managementBaseUri() { + var baseUri = server.configuration().baseUri(); + var address = (InetSocketAddress) managementServer.getListenerInfo().get(0).getAddress(); + + return UriBuilder.fromUri(baseUri).port(address.getPort()).build(); + } + public void start() throws ExecutionException, InterruptedException { logger.atInfo().log("\n" + BANNER); @@ -136,20 +149,31 @@ public void start() throws ExecutionException, InterruptedException { server = SeBootstrap.start( - new App( - config, keyStore, tokenIssuer, clientAuthenticator, meterRegistry, authService), + new App(config, keyStore, tokenIssuer, clientAuthenticator, authService), Configuration.builder().host(config.host()).port(config.port()).build()) .toCompletableFuture() .get(); var localUri = server.configuration().baseUri(); logger.atInfo().log("Magic at {} ({})", config.baseUri(), localUri); + + managementServer = + Undertow.builder() + .addHttpListener(config.managementPort(), config.host()) + .setHandler( + Handlers.path() + .addExactPath(HealthEndpoint.PATH, new HealthEndpoint()) + .addExactPath(MetricsEndpoint.PATH, new MetricsEndpoint(meterRegistry))) + .build(); + managementServer.start(); + + logger.atInfo().log("Management Server can be found at port {}", config.managementPort()); } private AuthenticationFlow buildAuthFlow( URI selfIssuer, URI fedmaster, JWKSet encJwks, HttpClient httpClient) { - // setup the file `.env.properties` to provide the X-Authorization header for the Gematik + // set up the file `.env.properties` to provide the X-Authorization header for the Gematik // test environment // see: https://wiki.gematik.de/display/IDPKB/Fachdienste+Test-Umgebungen var fedHttpClient = new GematikHeaderDecoratorHttpClient(new JavaHttpClient(httpClient)); diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java index 7e6a416..9e0fdb3 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java @@ -10,7 +10,6 @@ import com.oviva.ehealthid.relyingparty.svc.KeyStore; import com.oviva.ehealthid.relyingparty.svc.TokenIssuer; import com.oviva.ehealthid.util.JoseModule; -import io.micrometer.prometheus.PrometheusMeterRegistry; import jakarta.ws.rs.core.Application; import java.util.Set; @@ -20,7 +19,6 @@ public class App extends Application { private final KeyStore keyStore; private final TokenIssuer tokenIssuer; private final ClientAuthenticator clientAuthenticator; - private final PrometheusMeterRegistry prometheusMeterRegistry; private final AuthService authService; @@ -29,13 +27,11 @@ public App( KeyStore keyStore, TokenIssuer tokenIssuer, ClientAuthenticator clientAuthenticator, - PrometheusMeterRegistry prometheusMeterRegistry, AuthService authService) { this.config = config; this.keyStore = keyStore; this.tokenIssuer = tokenIssuer; this.clientAuthenticator = clientAuthenticator; - this.prometheusMeterRegistry = prometheusMeterRegistry; this.authService = authService; } @@ -47,9 +43,7 @@ public Set getSingletons() { new AuthEndpoint(authService), new TokenEndpoint(tokenIssuer, clientAuthenticator), new OpenIdEndpoint(config.baseUri(), config.relyingParty(), keyStore), - new JacksonJsonProvider(configureObjectMapper()), - new HealthEndpoint(), - new MetricsEndpoint(prometheusMeterRegistry)); + new JacksonJsonProvider(configureObjectMapper())); } @Override diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpoint.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpoint.java index f5ec37e..8d76101 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpoint.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpoint.java @@ -1,19 +1,29 @@ package com.oviva.ehealthid.relyingparty.ws; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; -@Path("/health") -public class HealthEndpoint { +public class HealthEndpoint implements HttpHandler { + public static final String PATH = "/health"; + + private static final int HTTP_METHOD_NOT_ALLOWED = 405; + private static final int HTTP_OK = 200; private static final String STATUS_UP = "{\"status\":\"UP\"}"; - @GET - public Response get() { - // For now if this endpoint is reachable then the service is up. There is no hard dependency - // that could be down. - return Response.ok(STATUS_UP).type(MediaType.APPLICATION_JSON_TYPE).build(); + @Override + public void handleRequest(HttpServerExchange httpServerExchange) { + if (!httpServerExchange.getRequestMethod().equals(HttpString.tryFromString("GET"))) { + httpServerExchange.setStatusCode(HTTP_METHOD_NOT_ALLOWED); + httpServerExchange.getResponseSender().send(""); + } else { + // For now if this endpoint is reachable then the service is up. + // There is no hard dependency that could be down. + httpServerExchange.setStatusCode(HTTP_OK); + httpServerExchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + httpServerExchange.getResponseSender().send(STATUS_UP); + } } } diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpoint.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpoint.java index f459495..e41cdf2 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpoint.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpoint.java @@ -6,13 +6,15 @@ import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; import io.micrometer.core.instrument.binder.system.ProcessorMetrics; import io.micrometer.prometheus.PrometheusMeterRegistry; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; -@Path("/metrics") -public class MetricsEndpoint { +public class MetricsEndpoint implements HttpHandler { + public static final String PATH = "/metrics"; + private static final int HTTP_OK = 200; + private static final int HTTP_METHOD_NOT_ALLOWED = 405; private final PrometheusMeterRegistry registry; @@ -26,9 +28,17 @@ public MetricsEndpoint(PrometheusMeterRegistry registry) { new JvmThreadMetrics().bindTo(this.registry); } - @GET - @Produces(MediaType.TEXT_PLAIN) - public String get() { - return registry.scrape(); + @Override + public void handleRequest(HttpServerExchange httpServerExchange) { + if (!httpServerExchange.getRequestMethod().equals(HttpString.tryFromString("GET"))) { + httpServerExchange.setStatusCode(HTTP_METHOD_NOT_ALLOWED); + httpServerExchange.getResponseSender().send(""); + } else { + httpServerExchange.setStatusCode(HTTP_OK); + httpServerExchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); + + var metricsContents = registry.scrape(); + httpServerExchange.getResponseSender().send(metricsContents); + } } } diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java index 43a467d..631aa9c 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java @@ -50,24 +50,28 @@ static void afterAll() throws Exception { @Test void run_smokeTest() { - var baseUri = application.baseUri(); + var managementBaseUri = application.managementBaseUri(); // then assertGetOk(baseUri.resolve(DISCOVERY_PATH)); assertGetOk(baseUri.resolve(JWKS_PATH)); assertGetOk(baseUri.resolve(FEDERATION_CONFIG_PATH)); - assertGetOk(baseUri.resolve(HEALTH_PATH)); - assertGetOk(baseUri.resolve(METRICS_PATH)); + + assertGetOk(managementBaseUri.resolve(HEALTH_PATH)); + assertGetOk(managementBaseUri.resolve(METRICS_PATH)); } @Test void run_metrics() { var baseUri = application.baseUri(); + var managementBaseUri = application.managementBaseUri(); // when & then - get(baseUri.resolve(METRICS_PATH)) + get(baseUri.resolve(METRICS_PATH)).then().statusCode(404); + + get(managementBaseUri.resolve(METRICS_PATH)) .then() .contentType(ContentType.TEXT) .body(containsString("cache_gets_total{cache=\"sessionCache\"")) diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java index 1d56d39..223f366 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java @@ -39,6 +39,7 @@ public URI start() throws ExecutionException, InterruptedException { redirect_uris=%s app_name=Awesome DiGA port=0 + management_port=0 """ .formatted(wireMockServer.baseUrl(), discoveryUri, redirectUri)); @@ -53,6 +54,10 @@ public URI baseUri() { return application.baseUri(); } + public URI managementBaseUri() { + return application.managementBaseUri(); + } + public Stubbing wireMockStubbing() { return wireMockServer; } diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpointTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpointTest.java index f7e0601..07e3a52 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpointTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/HealthEndpointTest.java @@ -1,7 +1,13 @@ package com.oviva.ehealthid.relyingparty.ws; -import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import io.undertow.io.Sender; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; import jakarta.ws.rs.core.Response.Status; import org.junit.jupiter.api.Test; @@ -12,9 +18,34 @@ void get() { var sut = new HealthEndpoint(); // when - var res = sut.get(); + var httpServerExchange = mock(HttpServerExchange.class); + var headers = mock(HeaderMap.class); + var sender = mock(Sender.class); + + when(httpServerExchange.getResponseHeaders()).thenReturn(headers); + when(httpServerExchange.getResponseSender()).thenReturn(sender); + when(httpServerExchange.getRequestMethod()).thenReturn(HttpString.tryFromString("GET")); + + sut.handleRequest(httpServerExchange); + + // then + verify(httpServerExchange).setStatusCode(Status.OK.getStatusCode()); + } + + @Test + void methodNotAllowed() { + var sut = new HealthEndpoint(); + + // when + var httpServerExchange = mock(HttpServerExchange.class); + var sender = mock(Sender.class); + + when(httpServerExchange.getResponseSender()).thenReturn(sender); + when(httpServerExchange.getRequestMethod()).thenReturn(HttpString.tryFromString("POST")); + + sut.handleRequest(httpServerExchange); // then - assertEquals(Status.OK.getStatusCode(), res.getStatus()); + verify(httpServerExchange).setStatusCode(Status.METHOD_NOT_ALLOWED.getStatusCode()); } } diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpointTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpointTest.java index 5550de5..2a5d822 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpointTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/MetricsEndpointTest.java @@ -1,15 +1,38 @@ package com.oviva.ehealthid.relyingparty.ws; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import io.micrometer.prometheus.PrometheusConfig; import io.micrometer.prometheus.PrometheusMeterRegistry; +import io.undertow.io.Sender; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; +import jakarta.ws.rs.core.Response.Status; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; +@ExtendWith(MockitoExtension.class) class MetricsEndpointTest { + @Captor ArgumentCaptor responseCaptor; + @Test void get() { + // when + var httpServerExchange = mock(HttpServerExchange.class); + var headers = mock(HeaderMap.class); + var sender = mock(Sender.class); + + when(httpServerExchange.getResponseHeaders()).thenReturn(headers); + when(httpServerExchange.getResponseSender()).thenReturn(sender); + when(httpServerExchange.getRequestMethod()).thenReturn(HttpString.tryFromString("GET")); // given var prometheusMeterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); @@ -21,9 +44,30 @@ void get() { var sut = new MetricsEndpoint(prometheusMeterRegistry); // when - var res = sut.get(); + sut.handleRequest(httpServerExchange); + + // then + verify(sender).send(responseCaptor.capture()); + assertTrue(responseCaptor.getValue().contains("test_counter_total 2.0")); + } + + @Test + void methodNotAllowed() { + // when + var httpServerExchange = mock(HttpServerExchange.class); + var sender = mock(Sender.class); + + when(httpServerExchange.getResponseSender()).thenReturn(sender); + when(httpServerExchange.getRequestMethod()).thenReturn(HttpString.tryFromString("POST")); + + // given + var prometheusMeterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + var sut = new MetricsEndpoint(prometheusMeterRegistry); + + // when + sut.handleRequest(httpServerExchange); // then - assertTrue(res.contains("test_counter_total 2.0")); + verify(httpServerExchange).setStatusCode(Status.METHOD_NOT_ALLOWED.getStatusCode()); } }