From 95fbc44a9a23987f3f0030a7c49dec2f04182ed0 Mon Sep 17 00:00:00 2001 From: Max Cao Date: Tue, 8 Aug 2023 10:12:15 -0700 Subject: [PATCH] feat(grafana): add grafana and jfr-datasource integration (#43) --- pom.xml | 19 ++ smoketest.sh | 7 + smoketest/compose/cryostat-grafana.yml | 18 ++ smoketest/compose/jfr-datasource.yml | 12 ++ smoketest/containers/smoketest_pod.sh | 4 + .../java/io/cryostat/ConfigProperties.java | 4 + src/main/java/io/cryostat/Health.java | 184 ++++++++++++++---- src/main/java/io/cryostat/Producers.java | 12 +- .../io/cryostat/credentials/Credential.java | 2 +- .../cryostat/discovery/CustomDiscovery.java | 2 +- .../java/io/cryostat/discovery/Discovery.java | 2 +- .../io/cryostat/discovery/DiscoveryNode.java | 2 +- .../cryostat/discovery/PodmanDiscovery.java | 47 +++-- .../cryostat/recordings/ActiveRecording.java | 6 +- .../cryostat/recordings/RecordingHelper.java | 107 ++++++++++ .../io/cryostat/recordings/Recordings.java | 59 +++++- .../RemoteRecordingInputStreamFactory.java | 22 ++- src/main/java/io/cryostat/rules/Rule.java | 2 +- src/main/java/io/cryostat/rules/Rules.java | 2 +- .../io/cryostat/targets/AgentConnection.java | 2 +- .../targets/AgentConnectionFactory.java | 4 +- .../io/cryostat/targets/AgentJFRService.java | 2 +- src/main/java/io/cryostat/targets/Target.java | 17 +- .../resources/application-test.properties | 3 + src/main/resources/application.properties | 1 - src/test/java/io/cryostat/HealthTest.java | 107 ++++++++++ .../cryostat/resources/GrafanaResource.java | 66 +++++++ .../resources/JFRDatasourceResource.java | 65 +++++++ .../java/io/cryostat/rules/RulesTest.java | 2 +- 29 files changed, 688 insertions(+), 94 deletions(-) create mode 100644 smoketest/compose/cryostat-grafana.yml create mode 100644 smoketest/compose/jfr-datasource.yml create mode 100644 src/test/java/io/cryostat/HealthTest.java create mode 100644 src/test/java/io/cryostat/resources/GrafanaResource.java create mode 100644 src/test/java/io/cryostat/resources/JFRDatasourceResource.java diff --git a/pom.xml b/pom.xml index a21bfd775..82fffadd9 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 3.12.0 1.7 0.3.21 + 1.18.3 quarkus-bom io.quarkus.platform 3.1.1.Final @@ -72,6 +73,13 @@ pom import + + org.testcontainers + testcontainers-bom + ${org.testcontainers.bom.version} + pom + import + @@ -215,6 +223,17 @@ rest-assured test + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + diff --git a/smoketest.sh b/smoketest.sh index d953c7f44..808f52946 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -7,6 +7,8 @@ cleanup() { docker-compose \ -f ./smoketest/compose/db.yml \ -f ./smoketest/compose/s3-minio.yml \ + -f ./smoketest/compose/cryostat-grafana.yml \ + -f ./smoketest/compose/jfr-datasource.yml \ -f ./smoketest/compose/sample-apps.yml \ -f ./smoketest/compose/cryostat.yml \ down --volumes --remove-orphans @@ -35,6 +37,8 @@ setupUserHosts() { echo "localhost db" >> ~/.hosts echo "localhost db-viewer" >> ~/.hosts echo "localhost cryostat" >> ~/.hosts + echo "localhost jfr-datasource" >> ~/.hosts + echo "localhost grafana" >> ~/.hosts echo "localhost vertx-fib-demo-1" >> ~/.hosts echo "localhost quarkus-test-agent" >> ~/.hosts } @@ -44,6 +48,9 @@ setupUserHosts docker-compose \ -f ./smoketest/compose/db.yml \ -f ./smoketest/compose/s3-minio.yml \ + -f ./smoketest/compose/cryostat-grafana.yml \ + -f ./smoketest/compose/jfr-datasource.yml \ -f ./smoketest/compose/sample-apps.yml \ -f ./smoketest/compose/cryostat.yml \ up + diff --git a/smoketest/compose/cryostat-grafana.yml b/smoketest/compose/cryostat-grafana.yml new file mode 100644 index 000000000..a779e476a --- /dev/null +++ b/smoketest/compose/cryostat-grafana.yml @@ -0,0 +1,18 @@ +version: "3" +services: + cryostat: + environment: + - GRAFANA_DASHBOARD_EXT_URL=http://localhost:3000 + - GRAFANA_DASHBOARD_URL=http://grafana:3000 + grafana: + image: quay.io/cryostat/cryostat-grafana-dashboard:latest + hostname: grafana + restart: unless-stopped + environment: + - GF_INSTALL_PLUGINS=grafana-simple-json-datasource + - GF_AUTH_ANONYMOUS_ENABLED=true + - JFR_DATASOURCE_URL=http://jfr-datasource:8080 + ports: + - "3000:3000" + expose: + - "3000" diff --git a/smoketest/compose/jfr-datasource.yml b/smoketest/compose/jfr-datasource.yml new file mode 100644 index 000000000..d7f9d5b0c --- /dev/null +++ b/smoketest/compose/jfr-datasource.yml @@ -0,0 +1,12 @@ +version: "3" +services: + cryostat: + environment: + - GRAFANA_DATASOURCE_URL=http://jfr-datasource:8080 + jfr-datasource: + image: quay.io/cryostat/jfr-datasource:latest + restart: unless-stopped + ports: + - "8080:8080" + expose: + - "8080" diff --git a/smoketest/containers/smoketest_pod.sh b/smoketest/containers/smoketest_pod.sh index 8719ca8d5..04178a655 100755 --- a/smoketest/containers/smoketest_pod.sh +++ b/smoketest/containers/smoketest_pod.sh @@ -7,6 +7,8 @@ cleanup() { podman-compose --in-pod=1 \ -f ./smoketest/compose/db.yml \ -f ./smoketest/compose/s3-minio.yml \ + -f ./smoketest/compose/cryostat-grafana.yml \ + -f ./smoketest/compose/jfr-datasource.yml \ -f ./smoketest/compose/sample-apps.yml \ -f ./smoketest/compose/cryostat.yml \ down --volumes --remove-orphans @@ -44,6 +46,8 @@ setupUserHosts podman-compose --in-pod=1 \ -f ./smoketest/compose/db.yml \ -f ./smoketest/compose/s3-minio.yml \ + -f ./smoketest/compose/cryostat-grafana.yml \ + -f ./smoketest/compose/jfr-datasource.yml \ -f ./smoketest/compose/sample-apps.yml \ -f ./smoketest/compose/cryostat.yml \ up diff --git a/src/main/java/io/cryostat/ConfigProperties.java b/src/main/java/io/cryostat/ConfigProperties.java index 5b8a8dd66..408ac3b49 100644 --- a/src/main/java/io/cryostat/ConfigProperties.java +++ b/src/main/java/io/cryostat/ConfigProperties.java @@ -17,4 +17,8 @@ public class ConfigProperties { public static final String AWS_BUCKET_NAME_ARCHIVES = "storage.buckets.archives.name"; + + public static final String GRAFANA_DASHBOARD_URL = "grafana-dashboard.url"; + public static final String GRAFANA_DASHBOARD_EXT_URL = "grafana-dashboard-ext.url"; + public static final String GRAFANA_DATASOURCE_URL = "grafana-datasource.url"; } diff --git a/src/main/java/io/cryostat/Health.java b/src/main/java/io/cryostat/Health.java index daeb03da8..eae06f085 100644 --- a/src/main/java/io/cryostat/Health.java +++ b/src/main/java/io/cryostat/Health.java @@ -15,14 +15,27 @@ */ package io.cryostat; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import io.cryostat.util.HttpStatusCodeIdentifier; + +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; +import io.vertx.mutiny.ext.web.client.WebClient; import jakarta.annotation.security.PermitAll; import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; @@ -47,36 +60,47 @@ class Health { @ConfigProperty(name = "quarkus.http.ssl.certificate.key-store-password") Optional sslPass; + @ConfigProperty(name = ConfigProperties.GRAFANA_DASHBOARD_URL) + Optional dashboardURL; + + @ConfigProperty(name = ConfigProperties.GRAFANA_DASHBOARD_EXT_URL) + Optional dashboardExternalURL; + + @ConfigProperty(name = ConfigProperties.GRAFANA_DATASOURCE_URL) + Optional datasourceURL; + @Inject Logger logger; + @Inject WebClient webClient; @GET @Path("/health") @PermitAll public Response health() { - return Response.ok( - Map.of( - "cryostatVersion", - String.format("v%s", version), - "dashboardConfigured", - false, - "dashboardAvailable", - false, - "datasourceConfigured", - false, - "datasourceAvailable", - false, - "reportsConfigured", - false, - "reportsAvailable", - false)) - .header("Access-Control-Allow-Origin", "http://localhost:9000") - .header( - "Access-Control-Allow-Headers", - "accept, origin, authorization, content-type," - + " x-requested-with, x-jmx-authorization") - .header("Access-Control-Expose-Headers", "x-www-authenticate, x-jmx-authenticate") - .header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") - .header("Access-Control-Allow-Credentials", "true") + CompletableFuture datasourceAvailable = new CompletableFuture<>(); + CompletableFuture dashboardAvailable = new CompletableFuture<>(); + CompletableFuture reportsAvailable = new CompletableFuture<>(); + + checkUri(dashboardURL, "/api/health", dashboardAvailable); + checkUri(datasourceURL, "/", datasourceAvailable); + reportsAvailable.complete(false); + + return new PermittedResponseBuilder( + Response.ok( + Map.of( + "cryostatVersion", + String.format("v%s", version), + "dashboardConfigured", + dashboardURL.isPresent(), + "dashboardAvailable", + dashboardAvailable.join(), + "datasourceConfigured", + datasourceURL.isPresent(), + "datasourceAvailable", + datasourceAvailable.join(), + "reportsConfigured", + false, + "reportsAvailable", + false))) .build(); } @@ -89,24 +113,102 @@ public void liveness() {} @Path("/api/v1/notifications_url") @PermitAll public Response notificationsUrl() { - // TODO @PermitAll annotation seems to skip the CORS filter, so these headers don't get - // added. We shouldn't need to add them manually like this and they should not be added in - // prod builds. boolean ssl = sslPass.isPresent(); - return Response.ok( - Map.of( - "notificationsUrl", - String.format( - "%s://%s:%d/api/v1/notifications", - ssl ? "wss" : "ws", host, ssl ? sslPort : port))) - .header("Access-Control-Allow-Origin", "http://localhost:9000") - .header( - "Access-Control-Allow-Headers", - "accept, origin, authorization, content-type," - + " x-requested-with, x-jmx-authorization") - .header("Access-Control-Expose-Headers", "x-www-authenticate, x-jmx-authenticate") - .header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") - .header("Access-Control-Allow-Credentials", "true") + return new PermittedResponseBuilder( + Response.ok( + Map.of( + "notificationsUrl", + String.format( + "%s://%s:%d/api/v1/notifications", + ssl ? "wss" : "ws", host, ssl ? sslPort : port)))) + .build(); + } + + @GET + @Path("/api/v1/grafana_dashboard_url") + @PermitAll + @Produces({MediaType.APPLICATION_JSON}) + public Response grafanaDashboardUrl() { + String url = + dashboardExternalURL.orElseGet( + () -> dashboardURL.orElseThrow(() -> new BadRequestException())); + + return new PermittedResponseBuilder(Response.ok(Map.of("grafanaDashboardUrl", url))) .build(); } + + @GET + @Path("/api/v1/grafana_datasource_url") + @PermitAll + @Produces({MediaType.APPLICATION_JSON}) + public Response grafanaDatasourceUrl() { + return new PermittedResponseBuilder( + Response.ok(Map.of("grafanaDatasourceUrl", datasourceURL))) + .corsSkippedHeaders() + .build(); + } + + private void checkUri( + Optional configProperty, String path, CompletableFuture future) { + if (configProperty.isPresent()) { + URI uri; + try { + uri = new URI(configProperty.get()); + } catch (URISyntaxException e) { + logger.error(e); + future.complete(false); + return; + } + logger.debugv("Testing health of {1}={2} {3}", configProperty, uri.toString(), path); + HttpRequest req = webClient.get(uri.getHost(), path); + if (uri.getPort() != -1) { + req = req.port(uri.getPort()); + } + req.ssl("https".equals(uri.getScheme())) + .timeout(5000) + .send() + .subscribe() + .with( + item -> { + future.complete( + HttpStatusCodeIdentifier.isSuccessCode(item.statusCode())); + }, + failure -> { + logger.warn(new IOException(failure)); + future.complete(false); + }); + } else { + future.complete(false); + } + } + + static class PermittedResponseBuilder { + private ResponseBuilder builder; + + public PermittedResponseBuilder(ResponseBuilder builder) { + this.builder = builder; + } + + public ResponseBuilder corsSkippedHeaders() { + // TODO @PermitAll annotation seems to skip the CORS filter, so these headers don't get + // added. We shouldn't need to add them manually like this and they should not be added + // in + // prod builds. + return this.builder + .header("Access-Control-Allow-Origin", "http://localhost:9000") + .header( + "Access-Control-Allow-Headers", + "accept, origin, authorization, content-type," + + " x-requested-with, x-jmx-authorization") + .header( + "Access-Control-Expose-Headers", + "x-www-authenticate, x-jmx-authenticate") + .header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + .header("Access-Control-Allow-Credentials", "true"); + } + + public Response build() { + return builder.build(); + } + } } diff --git a/src/main/java/io/cryostat/Producers.java b/src/main/java/io/cryostat/Producers.java index 2d802400f..2f7cf69b6 100644 --- a/src/main/java/io/cryostat/Producers.java +++ b/src/main/java/io/cryostat/Producers.java @@ -20,10 +20,11 @@ import java.util.concurrent.ScheduledExecutorService; import io.cryostat.core.sys.Clock; +import io.cryostat.core.sys.FileSystem; import io.quarkus.arc.DefaultBean; -import io.vertx.core.Vertx; -import io.vertx.ext.web.client.WebClient; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.ext.web.client.WebClient; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -43,6 +44,13 @@ public static Clock produceClock() { return new Clock(); } + @Produces + @ApplicationScoped + @DefaultBean + public static FileSystem produceFileSystem() { + return new FileSystem(); + } + @Produces @ApplicationScoped @DefaultBean diff --git a/src/main/java/io/cryostat/credentials/Credential.java b/src/main/java/io/cryostat/credentials/Credential.java index feafb5be1..7cdfd9bb7 100644 --- a/src/main/java/io/cryostat/credentials/Credential.java +++ b/src/main/java/io/cryostat/credentials/Credential.java @@ -19,7 +19,7 @@ import io.cryostat.ws.Notification; import io.quarkus.hibernate.orm.panache.PanacheEntity; -import io.vertx.core.eventbus.EventBus; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.Column; diff --git a/src/main/java/io/cryostat/discovery/CustomDiscovery.java b/src/main/java/io/cryostat/discovery/CustomDiscovery.java index 684aad96c..5779775b5 100644 --- a/src/main/java/io/cryostat/discovery/CustomDiscovery.java +++ b/src/main/java/io/cryostat/discovery/CustomDiscovery.java @@ -29,7 +29,7 @@ import io.cryostat.targets.TargetConnectionManager; import io.quarkus.runtime.StartupEvent; -import io.vertx.core.eventbus.EventBus; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; diff --git a/src/main/java/io/cryostat/discovery/Discovery.java b/src/main/java/io/cryostat/discovery/Discovery.java index 476f2db1b..e6d6b1ddd 100644 --- a/src/main/java/io/cryostat/discovery/Discovery.java +++ b/src/main/java/io/cryostat/discovery/Discovery.java @@ -28,8 +28,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.runtime.StartupEvent; -import io.vertx.core.eventbus.EventBus; import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.event.Observes; diff --git a/src/main/java/io/cryostat/discovery/DiscoveryNode.java b/src/main/java/io/cryostat/discovery/DiscoveryNode.java index 2e79b9d1d..16eb8a286 100644 --- a/src/main/java/io/cryostat/discovery/DiscoveryNode.java +++ b/src/main/java/io/cryostat/discovery/DiscoveryNode.java @@ -32,7 +32,7 @@ import io.quarkus.hibernate.orm.panache.PanacheEntity; import io.quarkus.vertx.ConsumeEvent; import io.smallrye.common.annotation.Blocking; -import io.vertx.core.eventbus.EventBus; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.CascadeType; diff --git a/src/main/java/io/cryostat/discovery/PodmanDiscovery.java b/src/main/java/io/cryostat/discovery/PodmanDiscovery.java index fd2f9e545..c92dde2ab 100644 --- a/src/main/java/io/cryostat/discovery/PodmanDiscovery.java +++ b/src/main/java/io/cryostat/discovery/PodmanDiscovery.java @@ -47,11 +47,11 @@ import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.StartupEvent; import io.smallrye.mutiny.infrastructure.Infrastructure; -import io.vertx.core.Vertx; import io.vertx.core.http.HttpMethod; -import io.vertx.core.net.SocketAddress; -import io.vertx.ext.web.client.WebClient; -import io.vertx.ext.web.codec.BodyCodec; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.core.net.SocketAddress; +import io.vertx.mutiny.ext.web.client.WebClient; +import io.vertx.mutiny.ext.web.codec.BodyCodec; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; @@ -162,26 +162,24 @@ private void doPodmanListRequest(Consumer> successHandler) { mapper.writeValueAsString(Map.of("label", List.of(DISCOVERY_LABEL)))) .timeout(2_000L) .as(BodyCodec.string()) - .send( - ar -> { - if (ar.failed()) { - Throwable t = ar.cause(); - logger.error("Podman API request failed", t); - return; - } + .send() + .subscribe() + .with( + item -> { try { successHandler.accept( mapper.readValue( - ar.result().body(), + item.body(), new TypeReference>() {})); } catch (JsonProcessingException e) { logger.error("Json processing error"); - return; } + }, + failure -> { + logger.error("Podman API request failed", failure); }); } catch (JsonProcessingException e) { logger.error("Json processing error"); - return; } } @@ -194,22 +192,21 @@ private CompletableFuture doPodmanInspectRequest(ContainerSpec .request(HttpMethod.GET, getSocket(), 80, "localhost", requestPath.toString()) .timeout(2_000L) .as(BodyCodec.string()) - .send( - ar -> { - if (ar.failed()) { - Throwable t = ar.cause(); - logger.error("Podman API request failed", t); - result.completeExceptionally(t); - return; - } + .send() + .subscribe() + .with( + item -> { try { result.complete( - mapper.readValue( - ar.result().body(), ContainerDetails.class)); + mapper.readValue(item.body(), ContainerDetails.class)); } catch (JsonProcessingException e) { logger.error("Json processing error"); - return; + result.completeExceptionally(e); } + }, + failure -> { + logger.error("Podman API request failed", failure); + result.completeExceptionally(failure); }); return result; } diff --git a/src/main/java/io/cryostat/recordings/ActiveRecording.java b/src/main/java/io/cryostat/recordings/ActiveRecording.java index 253d15cca..8f9b39988 100644 --- a/src/main/java/io/cryostat/recordings/ActiveRecording.java +++ b/src/main/java/io/cryostat/recordings/ActiveRecording.java @@ -32,7 +32,7 @@ import io.cryostat.ws.Notification; import io.quarkus.hibernate.orm.panache.PanacheEntity; -import io.vertx.core.eventbus.EventBus; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.Column; @@ -143,9 +143,9 @@ public static boolean deleteByName(String name) { return delete("name", name) > 0; } - public static boolean deleteFromTarget(Target target, String name) { + public static boolean deleteFromTarget(Target target, String recordingName) { ActiveRecording recordingToDelete = - find("target = ?1 and name = ?2", target, name).firstResult(); + find("target = ?1 and name = ?2", target, recordingName).firstResult(); if (recordingToDelete != null) { recordingToDelete.delete(); return true; diff --git a/src/main/java/io/cryostat/recordings/RecordingHelper.java b/src/main/java/io/cryostat/recordings/RecordingHelper.java index a9f49f889..6d387df0e 100644 --- a/src/main/java/io/cryostat/recordings/RecordingHelper.java +++ b/src/main/java/io/cryostat/recordings/RecordingHelper.java @@ -17,11 +17,14 @@ import java.io.IOException; import java.net.URI; +import java.net.URL; import java.net.URLDecoder; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -29,6 +32,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -42,6 +46,7 @@ import io.cryostat.ConfigProperties; import io.cryostat.core.net.JFRConnection; import io.cryostat.core.sys.Clock; +import io.cryostat.core.sys.FileSystem; import io.cryostat.core.templates.Template; import io.cryostat.core.templates.TemplateType; import io.cryostat.recordings.ActiveRecording.Listener.RecordingEvent; @@ -49,23 +54,29 @@ import io.cryostat.recordings.Recordings.Metadata; import io.cryostat.targets.Target; import io.cryostat.targets.TargetConnectionManager; +import io.cryostat.util.HttpMimeType; import io.cryostat.ws.MessagingServer; import io.cryostat.ws.Notification; import com.fasterxml.jackson.databind.ObjectMapper; import io.smallrye.common.annotation.Blocking; import io.vertx.mutiny.core.eventbus.EventBus; +import io.vertx.mutiny.ext.web.client.WebClient; +import io.vertx.mutiny.ext.web.multipart.MultipartForm; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; import jdk.jfr.RecordingState; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.server.jaxrs.ResponseBuilderImpl; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; @@ -89,6 +100,9 @@ public class RecordingHelper { private static final Pattern TEMPLATE_PATTERN = Pattern.compile("^template=([\\w]+)(?:,type=([\\w]+))?$"); private final Base64 base64Url = new Base64(0, null, true); + public static final String DATASOURCE_FILENAME = "cryostat-analysis.jfr"; + + private final long httpTimeoutSeconds = 5; // TODO: configurable client timeout @Inject Logger logger; @Inject TargetConnectionManager connectionManager; @@ -102,6 +116,9 @@ public class RecordingHelper { @Inject RemoteRecordingInputStreamFactory remoteRecordingStreamFactory; @Inject ObjectMapper mapper; @Inject S3Client storage; + @Inject FileSystem fs; + + @Inject WebClient webClient; @ConfigProperty(name = ConfigProperties.AWS_BUCKET_NAME_ARCHIVES) String archiveBucket; @@ -470,6 +487,83 @@ private Tagging createMetadataTagging(Metadata metadata) { .build(); } + // jfr-datasource handling + @Blocking + public Response uploadToJFRDatasource(long targetEntityId, long remoteId, URL uploadUrl) + throws Exception { + Target target = Target.getTargetById(targetEntityId); + Objects.requireNonNull(target, "Target from targetId not found"); + ActiveRecording recording = target.getRecordingById(remoteId); + Objects.requireNonNull(recording, "ActiveRecording from remoteId not found"); + Path recordingPath = + connectionManager.executeConnectedTask( + target, + connection -> { + return getRecordingCopyPath(connection, target, recording.name) + .orElseThrow( + () -> + new RecordingNotFoundException( + target.targetId(), recording.name)); + }); + + MultipartForm form = + MultipartForm.create() + .binaryFileUpload( + "file", + DATASOURCE_FILENAME, + recordingPath.toString(), + HttpMimeType.OCTET_STREAM.toString()); + + try { + ResponseBuilder builder = new ResponseBuilderImpl(); + var asyncRequest = + webClient + .postAbs(uploadUrl.toURI().resolve("/load").normalize().toString()) + .addQueryParam("overwrite", "true") + .timeout(TimeUnit.SECONDS.toMillis(httpTimeoutSeconds)) + .sendMultipartForm(form); + return asyncRequest + .onItem() + .transform( + r -> + builder.status(r.statusCode(), r.statusMessage()) + .entity(r.bodyAsString()) + .build()) + .onFailure() + .recoverWithItem( + (failure) -> { + logger.error(failure); + return Response.serverError().build(); + }) + .await() + .indefinitely(); // The timeout from the request should be sufficient + } finally { + fs.deleteIfExists(recordingPath); + } + } + + Optional getRecordingCopyPath( + JFRConnection connection, Target target, String recordingName) throws Exception { + return connection.getService().getAvailableRecordings().stream() + .filter(recording -> recording.getName().equals(recordingName)) + .findFirst() + .map( + descriptor -> { + try { + Path tempFile = fs.createTempFile(null, null); + try (var stream = + remoteRecordingStreamFactory.open( + connection, target, descriptor)) { + fs.copy(stream, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + return tempFile; + } catch (Exception e) { + logger.error(e); + throw new BadRequestException(e); + } + }); + } + public enum RecordingReplace { ALWAYS, NEVER, @@ -484,4 +578,17 @@ public static RecordingReplace fromString(String replace) { throw new IllegalArgumentException("Invalid recording replace value: " + replace); } } + + static class RecordingNotFoundException extends Exception { + public RecordingNotFoundException(String targetId, String recordingName) { + super( + String.format( + "Recording %s was not found in the target [%s].", + recordingName, targetId)); + } + + public RecordingNotFoundException(Pair key) { + this(key.getLeft(), key.getRight()); + } + } } diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java index 6d98fced7..a3eb14598 100644 --- a/src/main/java/io/cryostat/recordings/Recordings.java +++ b/src/main/java/io/cryostat/recordings/Recordings.java @@ -15,8 +15,10 @@ */ package io.cryostat.recordings; +import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; @@ -49,11 +51,12 @@ import io.cryostat.ws.MessagingServer; import io.cryostat.ws.Notification; +import com.arjuna.ats.jta.exceptions.NotImplementedException; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.runtime.StartupEvent; import io.smallrye.common.annotation.Blocking; -import io.vertx.core.eventbus.EventBus; import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; @@ -61,6 +64,7 @@ import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; +import jakarta.ws.rs.InternalServerErrorException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; @@ -70,6 +74,7 @@ import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.validator.routines.UrlValidator; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.RestForm; @@ -117,6 +122,9 @@ public class Recordings { @ConfigProperty(name = ConfigProperties.AWS_BUCKET_NAME_ARCHIVES) String archiveBucket; + @ConfigProperty(name = ConfigProperties.GRAFANA_DATASOURCE_URL) + Optional grafanaDatasourceURL; + void onStart(@Observes StartupEvent evt) { boolean exists = false; try { @@ -660,6 +668,55 @@ static void safeCloseRecording(JFRConnection conn, IRecordingDescriptor rec, Log } } + @POST + @Path("/api/v1/targets/{connectUrl}/recordings/{recordingName}/upload") + @RolesAllowed("write") + public Response uploadToGrafanaV1(@RestPath URI connectUrl, @RestPath String recordingName) { + Target target = Target.getTargetByConnectUrl(connectUrl); + long remoteId = + target.activeRecordings.stream() + .filter(r -> Objects.equals(r.name, recordingName)) + .findFirst() + .map(r -> r.remoteId) + .orElseThrow(() -> new NotFoundException()); + return Response.status(RestResponse.Status.PERMANENT_REDIRECT) + .location( + URI.create( + String.format( + "/api/v3/targets/%d/recordings/%d/upload", + target.id, remoteId))) + .build(); + } + + @POST + @Path("/api/v3/targets/{targetId}/recordings/{remoteId}/upload") + @RolesAllowed("write") + @Blocking + public Response uploadToGrafana(@RestPath long targetId, @RestPath long remoteId) + throws Exception { + try { + URL uploadUrl = + new URL( + grafanaDatasourceURL.orElseThrow( + () -> + new InternalServerErrorException( + "GRAFANA_DATASOURCE_URL environment variable" + + " does not exist"))); + boolean isValidUploadUrl = + new UrlValidator(UrlValidator.ALLOW_LOCAL_URLS).isValid(uploadUrl.toString()); + if (!isValidUploadUrl) { + throw new NotImplementedException( + String.format( + "$%s=%s is an invalid datasource URL", + ConfigProperties.GRAFANA_DATASOURCE_URL, uploadUrl.toString())); + } + + return recordingHelper.uploadToJFRDatasource(targetId, remoteId, uploadUrl); + } catch (MalformedURLException e) { + throw new NotImplementedException(e); + } + } + @GET @Path("/api/v1/targets/{connectUrl}/recordingOptions") @RolesAllowed("read") diff --git a/src/main/java/io/cryostat/recordings/RemoteRecordingInputStreamFactory.java b/src/main/java/io/cryostat/recordings/RemoteRecordingInputStreamFactory.java index fa18dc9b7..f3fc587b6 100644 --- a/src/main/java/io/cryostat/recordings/RemoteRecordingInputStreamFactory.java +++ b/src/main/java/io/cryostat/recordings/RemoteRecordingInputStreamFactory.java @@ -20,6 +20,7 @@ import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; import io.cryostat.ProgressInputStream; +import io.cryostat.core.net.JFRConnection; import io.cryostat.targets.Target; import io.cryostat.targets.TargetConnectionManager; @@ -33,15 +34,18 @@ public class RemoteRecordingInputStreamFactory { public ProgressInputStream open(Target target, ActiveRecording activeRecording) throws Exception { - InputStream bareStream = - connectionManager.executeConnectedTask( - target, - conn -> { - IRecordingDescriptor desc = - RecordingHelper.getDescriptor(conn, activeRecording) - .orElseThrow(); - return conn.getService().openStream(desc, false); - }); + return connectionManager.executeConnectedTask( + target, + conn -> { + IRecordingDescriptor desc = + RecordingHelper.getDescriptor(conn, activeRecording).orElseThrow(); + return open(conn, target, desc); + }); + } + + public ProgressInputStream open(JFRConnection conn, Target target, IRecordingDescriptor desc) + throws Exception { + InputStream bareStream = conn.getService().openStream(desc, false); return new ProgressInputStream( bareStream, n -> connectionManager.markConnectionInUse(target)); } diff --git a/src/main/java/io/cryostat/rules/Rule.java b/src/main/java/io/cryostat/rules/Rule.java index ef4a83745..930389e4a 100644 --- a/src/main/java/io/cryostat/rules/Rule.java +++ b/src/main/java/io/cryostat/rules/Rule.java @@ -19,7 +19,7 @@ import io.cryostat.ws.Notification; import io.quarkus.hibernate.orm.panache.PanacheEntity; -import io.vertx.core.eventbus.EventBus; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.Column; diff --git a/src/main/java/io/cryostat/rules/Rules.java b/src/main/java/io/cryostat/rules/Rules.java index 71e2a588b..477d52552 100644 --- a/src/main/java/io/cryostat/rules/Rules.java +++ b/src/main/java/io/cryostat/rules/Rules.java @@ -17,8 +17,8 @@ import io.cryostat.V2Response; -import io.vertx.core.eventbus.EventBus; import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; diff --git a/src/main/java/io/cryostat/targets/AgentConnection.java b/src/main/java/io/cryostat/targets/AgentConnection.java index 9dc53a0c3..100123a94 100644 --- a/src/main/java/io/cryostat/targets/AgentConnection.java +++ b/src/main/java/io/cryostat/targets/AgentConnection.java @@ -46,7 +46,7 @@ import io.cryostat.core.templates.TemplateService; import io.cryostat.core.templates.TemplateType; -import io.vertx.ext.web.client.WebClient; +import io.vertx.mutiny.ext.web.client.WebClient; import org.jsoup.nodes.Document; class AgentConnection implements JFRConnection { diff --git a/src/main/java/io/cryostat/targets/AgentConnectionFactory.java b/src/main/java/io/cryostat/targets/AgentConnectionFactory.java index a179a1eee..6b87538b7 100644 --- a/src/main/java/io/cryostat/targets/AgentConnectionFactory.java +++ b/src/main/java/io/cryostat/targets/AgentConnectionFactory.java @@ -19,8 +19,8 @@ import io.cryostat.core.sys.Clock; -import io.vertx.core.Vertx; -import io.vertx.ext.web.client.WebClient; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.ext.web.client.WebClient; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; diff --git a/src/main/java/io/cryostat/targets/AgentJFRService.java b/src/main/java/io/cryostat/targets/AgentJFRService.java index 29afbd762..e72112512 100644 --- a/src/main/java/io/cryostat/targets/AgentJFRService.java +++ b/src/main/java/io/cryostat/targets/AgentJFRService.java @@ -32,7 +32,7 @@ import org.openjdk.jmc.rjmx.services.jfr.IFlightRecorderService; import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; -import io.vertx.ext.web.client.WebClient; +import io.vertx.mutiny.ext.web.client.WebClient; class AgentJFRService implements IFlightRecorderService { diff --git a/src/main/java/io/cryostat/targets/Target.java b/src/main/java/io/cryostat/targets/Target.java index 4ddd83c23..3e0641bca 100644 --- a/src/main/java/io/cryostat/targets/Target.java +++ b/src/main/java/io/cryostat/targets/Target.java @@ -36,7 +36,7 @@ import io.quarkus.hibernate.orm.panache.PanacheEntity; import io.quarkus.vertx.ConsumeEvent; import io.smallrye.common.annotation.Blocking; -import io.vertx.core.eventbus.EventBus; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.CascadeType; @@ -96,6 +96,14 @@ public boolean isAgent() { return Set.of("http", "https", "cryostat-agent").contains(connectUrl.getScheme()); } + public String targetId() { + return this.connectUrl.toString(); + } + + public static Target getTargetById(long targetId) { + return Target.find("id", targetId).singleResult(); + } + public static Target getTargetByConnectUrl(URI connectUrl) { return find("connectUrl", connectUrl).singleResult(); } @@ -104,6 +112,13 @@ public static boolean deleteByConnectUrl(URI connectUrl) { return delete("connectUrl", connectUrl) > 0; } + public ActiveRecording getRecordingById(long remoteId) { + return activeRecordings.stream() + .filter(rec -> rec.remoteId == remoteId) + .findFirst() + .orElse(null); + } + public static class Annotations { public Map platform; public Map cryostat; diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 3c39b7fca..74ccf091e 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -3,3 +3,6 @@ quarkus.smallrye-openapi.info-title=Cryostat API (test) cryostat.discovery.jdp.enabled=true cryostat.discovery.podman.enabled=true cryostat.auth.disabled=true + +grafana-dashboard.url=http://grafana:3000 +grafana-datasource.url=http://jfr-datasource:8080 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3c13aa3a9..4f7da7561 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,6 @@ quarkus.naming.enable-jndi=true cryostat.discovery.jdp.enabled=false cryostat.discovery.podman.enabled=false - quarkus.test.integration-test-profile=test quarkus.http.auth.proactive=false diff --git a/src/test/java/io/cryostat/HealthTest.java b/src/test/java/io/cryostat/HealthTest.java new file mode 100644 index 000000000..373fc0916 --- /dev/null +++ b/src/test/java/io/cryostat/HealthTest.java @@ -0,0 +1,107 @@ +/* + * Copyright The Cryostat 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 + * + * 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 io.cryostat; + +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.*; + +import java.util.Optional; + +import io.cryostat.resources.GrafanaResource; +import io.cryostat.resources.JFRDatasourceResource; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@QuarkusTestResource(GrafanaResource.class) +@QuarkusTestResource(JFRDatasourceResource.class) +@TestHTTPEndpoint(Health.class) +public class HealthTest { + + @ConfigProperty(name = "quarkus.http.host") + String host; + + @ConfigProperty(name = "quarkus.http.port") + int port; + + @ConfigProperty(name = "quarkus.http.ssl-port") + int sslPort; + + @ConfigProperty(name = "quarkus.http.ssl.certificate.key-store-password") + Optional sslPass; + + @ConfigProperty(name = ConfigProperties.GRAFANA_DASHBOARD_URL) + Optional dashboardURL; + + @ConfigProperty(name = ConfigProperties.GRAFANA_DATASOURCE_URL) + Optional datasourceURL; + + @Test + public void testHealth() { + when().get("/health") + .then() + .statusCode(200) + .body( + "cryostatVersion", Matchers.instanceOf(String.class), + "dashboardConfigured", is(true), + "dashboardAvailable", is(true), + "datasourceConfigured", is(true), + "datasourceAvailable", is(true), + "reportsConfigured", is(false), + "reportsAvailable", is(false)); + } + + @Test + public void testHealthLiveness() { + when().get("/health/liveness").then().statusCode(204); + } + + @Test + public void testNotificationsUrl() { + boolean ssl = sslPass.isPresent(); + when().get("/api/v1/notifications_url") + .then() + .statusCode(200) + .body( + "notificationsUrl", + is( + String.format( + "%s://%s:%d/api/v1/notifications", + ssl ? "wss" : "ws", host, ssl ? sslPort : port))); + } + + @Test + public void testGrafanaDashboardUrl() { + when().get("/api/v1/grafana_dashboard_url") + .then() + .statusCode(200) + .body("grafanaDashboardUrl", is(dashboardURL.orElseGet(() -> "badurl"))); + } + + @Test + public void testGrafanaDatasourceUrl() { + when().get("/api/v1/grafana_datasource_url") + .then() + .statusCode(200) + .body("grafanaDatasourceUrl", is(datasourceURL.orElseGet(() -> "badurl"))); + } +} diff --git a/src/test/java/io/cryostat/resources/GrafanaResource.java b/src/test/java/io/cryostat/resources/GrafanaResource.java new file mode 100644 index 000000000..410369e36 --- /dev/null +++ b/src/test/java/io/cryostat/resources/GrafanaResource.java @@ -0,0 +1,66 @@ +/* + * Copyright The Cryostat 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 + * + * 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 io.cryostat.resources; + +import java.util.Map; +import java.util.Optional; + +import io.quarkus.test.common.DevServicesContext; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class GrafanaResource + implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { + + private static int GRAFANA_PORT = 3000; + private static String IMAGE_NAME = "quay.io/cryostat/cryostat-grafana-dashboard:latest"; + private static Map envMap = + Map.of( + "GF_INSTALL_PLUGINS", "grafana-simple-json-datasource", + "GF_AUTH_ANONYMOUS_ENABLED", "tru", + "JFR_DATASOURCE_URL", "http://jfr-datasource:8080"); + + private Optional containerNetworkId; + private GenericContainer container; + + @Override + public Map start() { + container = + new GenericContainer<>(DockerImageName.parse(IMAGE_NAME)) + .withExposedPorts(GRAFANA_PORT) + .withEnv(envMap) + .withLogConsumer(outputFrame -> {}); + containerNetworkId.ifPresent(container::withNetworkMode); + + container.start(); + + String networkHostPort = + "http://" + container.getHost() + ":" + container.getMappedPort(GRAFANA_PORT); + + return Map.of("grafana-dashboard.url", networkHostPort); + } + + @Override + public void stop() { + container.stop(); + } + + @Override + public void setIntegrationTestContext(DevServicesContext context) { + containerNetworkId = context.containerNetworkId(); + } +} diff --git a/src/test/java/io/cryostat/resources/JFRDatasourceResource.java b/src/test/java/io/cryostat/resources/JFRDatasourceResource.java new file mode 100644 index 000000000..f098f9949 --- /dev/null +++ b/src/test/java/io/cryostat/resources/JFRDatasourceResource.java @@ -0,0 +1,65 @@ +/* + * Copyright The Cryostat 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 + * + * 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 io.cryostat.resources; + +import java.util.Map; +import java.util.Optional; + +import io.quarkus.test.common.DevServicesContext; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class JFRDatasourceResource + implements QuarkusTestResourceLifecycleManager, DevServicesContext.ContextAware { + + private static int JFR_DATASOURCE_PORT = 8080; + private static String IMAGE_NAME = "quay.io/cryostat/jfr-datasource:latest"; + private static Map envMap = Map.of(); + + private Optional containerNetworkId; + private GenericContainer container; + + @Override + public Map start() { + container = + new GenericContainer<>(DockerImageName.parse(IMAGE_NAME)) + .withExposedPorts(JFR_DATASOURCE_PORT) + .withEnv(envMap) + .withLogConsumer(outputFrame -> {}); + containerNetworkId.ifPresent(container::withNetworkMode); + + container.start(); + + String networkHostPort = + "http://" + + container.getHost() + + ":" + + container.getMappedPort(JFR_DATASOURCE_PORT); + + return Map.of("grafana-datasource.url", networkHostPort); + } + + @Override + public void stop() { + container.stop(); + } + + @Override + public void setIntegrationTestContext(DevServicesContext context) { + containerNetworkId = context.containerNetworkId(); + } +} diff --git a/src/test/java/io/cryostat/rules/RulesTest.java b/src/test/java/io/cryostat/rules/RulesTest.java index f3fe10d71..837adb06f 100644 --- a/src/test/java/io/cryostat/rules/RulesTest.java +++ b/src/test/java/io/cryostat/rules/RulesTest.java @@ -24,8 +24,8 @@ import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.mockito.InjectSpy; import io.restassured.http.ContentType; -import io.vertx.core.eventbus.EventBus; import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.transaction.Transactional; import jakarta.ws.rs.core.MediaType; import org.hamcrest.Matchers;