From 5be5cf055a70b2d287aaa2231f8c385f00ff1ab8 Mon Sep 17 00:00:00 2001 From: Andrew Azores Date: Wed, 18 Aug 2021 22:16:02 -0400 Subject: [PATCH] feat(health): add cryostatVersion --- .../java/io/cryostat/ApplicationVersion.java | 77 ++++++++++++++++++ src/main/java/io/cryostat/MainModule.java | 6 ++ .../web/http/generic/HealthGetHandler.java | 12 ++- .../http/generic/HealthGetHandlerTest.java | 64 +++++++++++---- src/test/java/itest/HealthIT.java | 81 +++++++++++++++++++ 5 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 src/main/java/io/cryostat/ApplicationVersion.java create mode 100644 src/test/java/itest/HealthIT.java diff --git a/src/main/java/io/cryostat/ApplicationVersion.java b/src/main/java/io/cryostat/ApplicationVersion.java new file mode 100644 index 0000000000..825a69b76f --- /dev/null +++ b/src/main/java/io/cryostat/ApplicationVersion.java @@ -0,0 +1,77 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import io.cryostat.core.log.Logger; + +public class ApplicationVersion { + + private volatile String version; + private final Logger logger; + + ApplicationVersion(Logger logger) { + this.logger = logger; + } + + public synchronized String getVersionString() { + if (version == null) { + try (BufferedReader br = + new BufferedReader( + new InputStreamReader( + getClass().getResourceAsStream("/io/cryostat/version"), + StandardCharsets.UTF_8))) { + version = + br.lines() + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "Resource file /io/cryostat/version is empty")) + .trim(); + } catch (Exception e) { + logger.error(e); + version = "unknown"; + } + } + return version; + } +} diff --git a/src/main/java/io/cryostat/MainModule.java b/src/main/java/io/cryostat/MainModule.java index a53e6e2506..5f19a96b1a 100644 --- a/src/main/java/io/cryostat/MainModule.java +++ b/src/main/java/io/cryostat/MainModule.java @@ -85,6 +85,12 @@ public abstract class MainModule { public static final String RECORDINGS_PATH = "RECORDINGS_PATH"; public static final String CONF_DIR = "CONF_DIR"; + @Provides + @Singleton + static ApplicationVersion provideApplicationVersion(Logger logger) { + return new ApplicationVersion(logger); + } + @Provides @Singleton static Logger provideLogger() { diff --git a/src/main/java/io/cryostat/net/web/http/generic/HealthGetHandler.java b/src/main/java/io/cryostat/net/web/http/generic/HealthGetHandler.java index eab6486904..b206da2fae 100644 --- a/src/main/java/io/cryostat/net/web/http/generic/HealthGetHandler.java +++ b/src/main/java/io/cryostat/net/web/http/generic/HealthGetHandler.java @@ -46,6 +46,7 @@ import javax.inject.Inject; +import io.cryostat.ApplicationVersion; import io.cryostat.core.log.Logger; import io.cryostat.core.sys.Environment; import io.cryostat.net.security.ResourceAction; @@ -66,13 +67,20 @@ class HealthGetHandler implements RequestHandler { static final String GRAFANA_DATASOURCE_ENV = "GRAFANA_DATASOURCE_URL"; static final String GRAFANA_DASHBOARD_ENV = "GRAFANA_DASHBOARD_URL"; + private final ApplicationVersion appVersion; private final WebClient webClient; private final Environment env; private final Gson gson; private final Logger logger; @Inject - HealthGetHandler(WebClient webClient, Environment env, Gson gson, Logger logger) { + HealthGetHandler( + ApplicationVersion appVersion, + WebClient webClient, + Environment env, + Gson gson, + Logger logger) { + this.appVersion = appVersion; this.webClient = webClient; this.env = env; this.gson = gson; @@ -117,6 +125,8 @@ public void handle(RoutingContext ctx) { .end( gson.toJson( Map.of( + "cryostatVersion", + appVersion.getVersionString(), "dashboardAvailable", dashboardAvailable.join(), "datasourceAvailable", diff --git a/src/test/java/io/cryostat/net/web/http/generic/HealthGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/generic/HealthGetHandlerTest.java index b6f79fcc1d..bb3585103c 100644 --- a/src/test/java/io/cryostat/net/web/http/generic/HealthGetHandlerTest.java +++ b/src/test/java/io/cryostat/net/web/http/generic/HealthGetHandlerTest.java @@ -43,6 +43,7 @@ import java.util.Map; +import io.cryostat.ApplicationVersion; import io.cryostat.MainModule; import io.cryostat.core.log.Logger; import io.cryostat.core.sys.Environment; @@ -77,6 +78,7 @@ class HealthGetHandlerTest { HealthGetHandler handler; + @Mock ApplicationVersion appVersion; @Mock WebClient webClient; @Mock Environment env; @Mock Logger logger; @@ -84,7 +86,7 @@ class HealthGetHandlerTest { @BeforeEach void setup() { - this.handler = new HealthGetHandler(webClient, env, gson, logger); + this.handler = new HealthGetHandler(appVersion, webClient, env, gson, logger); } @Test @@ -109,18 +111,25 @@ void shouldHandleHealthRequest() { when(ctx.response()).thenReturn(rep); when(rep.putHeader(Mockito.any(CharSequence.class), Mockito.anyString())).thenReturn(rep); + when(appVersion.getVersionString()).thenReturn("v1.2.3"); + handler.handle(ctx); verify(rep).putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.JSON.mime()); ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); verify(rep).end(responseCaptor.capture()); - Map responseMap = + Map responseMap = gson.fromJson( responseCaptor.getValue(), - new TypeToken>() {}.getType()); - Assertions.assertEquals(responseMap.get("dashboardAvailable"), false); - Assertions.assertEquals(responseMap.get("datasourceAvailable"), false); + new TypeToken>() {}.getType()); + MatcherAssert.assertThat( + responseMap, + Matchers.equalTo( + Map.of( + "cryostatVersion", "v1.2.3", + "dashboardAvailable", false, + "datasourceAvailable", false))); } @Test @@ -130,6 +139,8 @@ void shouldHandleHealthRequestWithDatasourceUrl() { when(ctx.response()).thenReturn(rep); when(rep.putHeader(Mockito.any(CharSequence.class), Mockito.anyString())).thenReturn(rep); + when(appVersion.getVersionString()).thenReturn("v1.2.3"); + String url = "http://hostname:1/"; when(env.hasEnv("GRAFANA_DATASOURCE_URL")).thenReturn(true); when(env.getEnv("GRAFANA_DATASOURCE_URL")).thenReturn(url); @@ -163,12 +174,17 @@ public Void answer(InvocationOnMock args) throws Throwable { ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); verify(rep).end(responseCaptor.capture()); - Map responseMap = + Map responseMap = gson.fromJson( responseCaptor.getValue(), - new TypeToken>() {}.getType()); - Assertions.assertEquals(responseMap.get("dashboardAvailable"), false); - Assertions.assertEquals(responseMap.get("datasourceAvailable"), true); + new TypeToken>() {}.getType()); + MatcherAssert.assertThat( + responseMap, + Matchers.equalTo( + Map.of( + "cryostatVersion", "v1.2.3", + "dashboardAvailable", false, + "datasourceAvailable", true))); } @Test @@ -178,6 +194,8 @@ void shouldHandleHealthRequestWithDashboardUrl() { when(ctx.response()).thenReturn(rep); when(rep.putHeader(Mockito.any(CharSequence.class), Mockito.anyString())).thenReturn(rep); + when(appVersion.getVersionString()).thenReturn("v1.2.3"); + String url = "http://hostname:1/"; when(env.hasEnv("GRAFANA_DASHBOARD_URL")).thenReturn(true); when(env.getEnv("GRAFANA_DASHBOARD_URL")).thenReturn(url); @@ -211,12 +229,17 @@ public Void answer(InvocationOnMock args) throws Throwable { ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); verify(rep).end(responseCaptor.capture()); - Map responseMap = + Map responseMap = gson.fromJson( responseCaptor.getValue(), - new TypeToken>() {}.getType()); - Assertions.assertEquals(responseMap.get("dashboardAvailable"), true); - Assertions.assertEquals(responseMap.get("datasourceAvailable"), false); + new TypeToken>() {}.getType()); + MatcherAssert.assertThat( + responseMap, + Matchers.equalTo( + Map.of( + "cryostatVersion", "v1.2.3", + "dashboardAvailable", true, + "datasourceAvailable", false))); } @Test @@ -226,6 +249,8 @@ void shouldHandleHealthRequestWithDashboardUrlWithoutExplicitPort() { when(ctx.response()).thenReturn(rep); when(rep.putHeader(Mockito.any(CharSequence.class), Mockito.anyString())).thenReturn(rep); + when(appVersion.getVersionString()).thenReturn("v1.2.3"); + String url = "https://hostname/"; when(env.hasEnv("GRAFANA_DASHBOARD_URL")).thenReturn(true); when(env.getEnv("GRAFANA_DASHBOARD_URL")).thenReturn(url); @@ -258,11 +283,16 @@ public Void answer(InvocationOnMock args) throws Throwable { ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); verify(rep).end(responseCaptor.capture()); - Map responseMap = + Map responseMap = gson.fromJson( responseCaptor.getValue(), - new TypeToken>() {}.getType()); - Assertions.assertEquals(responseMap.get("dashboardAvailable"), true); - Assertions.assertEquals(responseMap.get("datasourceAvailable"), false); + new TypeToken>() {}.getType()); + MatcherAssert.assertThat( + responseMap, + Matchers.equalTo( + Map.of( + "cryostatVersion", "v1.2.3", + "dashboardAvailable", true, + "datasourceAvailable", false))); } } diff --git a/src/test/java/itest/HealthIT.java b/src/test/java/itest/HealthIT.java new file mode 100644 index 0000000000..2981c10fb3 --- /dev/null +++ b/src/test/java/itest/HealthIT.java @@ -0,0 +1,81 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpRequest; +import itest.bases.StandardSelfTest; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class HealthIT extends StandardSelfTest { + + HttpRequest req; + + @BeforeEach + void createRequest() { + req = webClient.get("/health"); + } + + @Test + void shouldIncludeApplicationVersion() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + req.send( + ar -> { + if (ar.failed()) { + future.completeExceptionally(ar.cause()); + return; + } + future.complete(ar.result().bodyAsJsonObject()); + }); + JsonObject response = future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + Assertions.assertTrue(response.containsKey("cryostatVersion")); + MatcherAssert.assertThat( + response.getString("cryostatVersion"), Matchers.not(Matchers.emptyOrNullString())); + MatcherAssert.assertThat( + response.getString("cryostatVersion"), Matchers.not(Matchers.equalTo("unknown"))); + } +}