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;