From afd746f44444e3e744b9423b4377dff4ee6f848c Mon Sep 17 00:00:00 2001 From: Andrew Azores Date: Fri, 16 Feb 2024 12:32:16 -0500 Subject: [PATCH] feat(grafana): implement endpoint for 'view in grafana' of archived recordings (#287) --- smoketest.bash | 19 ++- .../java/io/cryostat/ExceptionMappers.java | 16 ++ .../cryostat/recordings/RecordingHelper.java | 140 ++++++++++++++---- .../io/cryostat/recordings/Recordings.java | 62 ++++---- 4 files changed, 172 insertions(+), 65 deletions(-) diff --git a/smoketest.bash b/smoketest.bash index 29c9e77ec..d9fbffb02 100755 --- a/smoketest.bash +++ b/smoketest.bash @@ -18,7 +18,7 @@ OPEN_TABS=${OPEN_TABS:-false} CRYOSTAT_HTTP_PORT=8080 USE_PROXY=${USE_PROXY:-true} -DEPLOY_GRAFANA=false +DEPLOY_GRAFANA=true display_usage() { echo "Usage:" @@ -26,7 +26,7 @@ display_usage() { echo -e "\t-O\t\t\t\t\t\tOffline mode, do not attempt to pull container images." echo -e "\t-p\t\t\t\t\t\tDisable auth Proxy." echo -e "\t-s [seaweed|minio|cloudserver|localstack]\tS3 implementation to spin up (default \"seaweed\")." - echo -e "\t-g\t\t\t\t\t\tinclude Grafana dashboard and jfr-datasource in deployment." + echo -e "\t-G\t\t\t\t\t\texclude Grafana dashboard and jfr-datasource from deployment." echo -e "\t-r\t\t\t\t\t\tconfigure a cryostat-Reports sidecar instance" echo -e "\t-t\t\t\t\t\t\tinclude sample applications for Testing." echo -e "\t-V\t\t\t\t\t\tdo not discard data storage Volumes on exit." @@ -37,7 +37,7 @@ display_usage() { s3=seaweed ce=podman -while getopts "hs:prgtOVXcb" opt; do +while getopts "hs:prGtOVXcb" opt; do case $opt in h) display_usage @@ -49,9 +49,8 @@ while getopts "hs:prgtOVXcb" opt; do s) s3="${OPTARG}" ;; - g) - FILES+=("${DIR}/smoketest/compose/cryostat-grafana.yml" "${DIR}/smoketest/compose/jfr-datasource.yml") - DEPLOY_GRAFANA=true + G) + DEPLOY_GRAFANA=false ;; t) FILES+=("${DIR}/smoketest/compose/sample-apps.yml") @@ -81,6 +80,14 @@ while getopts "hs:prgtOVXcb" opt; do esac done +if [ "${DEPLOY_GRAFANA}" = "true" ]; then + FILES+=( + "${DIR}/smoketest/compose/cryostat-grafana.yml" + "${DIR}/smoketest/compose/jfr-datasource.yml" + ) +fi + + if [ "${USE_PROXY}" = "true" ]; then FILES+=("${DIR}/smoketest/compose/auth_proxy.yml") CRYOSTAT_HTTP_PORT=8181 diff --git a/src/main/java/io/cryostat/ExceptionMappers.java b/src/main/java/io/cryostat/ExceptionMappers.java index 593ea1484..3c166ef92 100644 --- a/src/main/java/io/cryostat/ExceptionMappers.java +++ b/src/main/java/io/cryostat/ExceptionMappers.java @@ -15,6 +15,9 @@ */ package io.cryostat; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; + import org.openjdk.jmc.rjmx.ConnectionException; import io.cryostat.targets.TargetConnectionManager; @@ -24,6 +27,7 @@ import io.smallrye.mutiny.TimeoutException; import jakarta.inject.Inject; import jakarta.persistence.NoResultException; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.hibernate.exception.ConstraintViolationException; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.RestResponse; @@ -100,4 +104,16 @@ public RestResponse mapEntityExistsException(EntityExistsException ex) { .entity(ex.getMessage()) .build(); } + + @ServerExceptionMapper + public RestResponse mapCompletionException(CompletionException ex) throws Throwable { + logger.warn(ex); + throw ExceptionUtils.getRootCause(ex); + } + + @ServerExceptionMapper + public RestResponse mapExecutionException(ExecutionException ex) throws Throwable { + logger.warn(ex); + throw ExceptionUtils.getRootCause(ex); + } } diff --git a/src/main/java/io/cryostat/recordings/RecordingHelper.java b/src/main/java/io/cryostat/recordings/RecordingHelper.java index aecb9a073..fc8ab8454 100644 --- a/src/main/java/io/cryostat/recordings/RecordingHelper.java +++ b/src/main/java/io/cryostat/recordings/RecordingHelper.java @@ -17,6 +17,8 @@ import java.io.IOException; import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; import java.nio.ByteBuffer; @@ -35,6 +37,8 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -64,23 +68,28 @@ import io.cryostat.ws.MessagingServer; import io.cryostat.ws.Notification; +import io.quarkus.runtime.StartupEvent; +import io.smallrye.common.annotation.Blocking; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.handler.HttpException; import io.vertx.mutiny.core.eventbus.EventBus; +import io.vertx.mutiny.ext.web.client.HttpResponse; import io.vertx.mutiny.ext.web.client.WebClient; import io.vertx.mutiny.ext.web.multipart.MultipartForm; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.inject.Named; 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.apache.commons.validator.routines.UrlValidator; +import org.apache.hc.core5.http.HttpStatus; 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; @@ -135,6 +144,49 @@ public class RecordingHelper { @ConfigProperty(name = ConfigProperties.CONNECTIONS_FAILED_TIMEOUT) Duration connectionFailedTimeout; + @ConfigProperty(name = ConfigProperties.GRAFANA_DATASOURCE_URL) + Optional grafanaDatasourceURLProperty; + + CompletableFuture grafanaDatasourceURL = new CompletableFuture<>(); + + void onStart(@Observes StartupEvent evt) { + if (grafanaDatasourceURLProperty.isEmpty()) { + grafanaDatasourceURL.completeExceptionally( + new HttpException( + HttpStatus.SC_BAD_GATEWAY, + String.format( + "Configuration property %s is not set", + ConfigProperties.GRAFANA_DATASOURCE_URL))); + return; + } + try { + URL uploadUrl = + new URL(grafanaDatasourceURLProperty.orElseThrow(() -> new HttpException())); + boolean isValidUploadUrl = + new UrlValidator(UrlValidator.ALLOW_LOCAL_URLS).isValid(uploadUrl.toString()); + if (!isValidUploadUrl) { + grafanaDatasourceURL.completeExceptionally( + new HttpException( + HttpStatus.SC_BAD_GATEWAY, + String.format( + "Configuration property %s=%s is not acceptable", + ConfigProperties.GRAFANA_DATASOURCE_URL, + grafanaDatasourceURLProperty.get()))); + return; + } + grafanaDatasourceURL.complete(new URL(grafanaDatasourceURLProperty.get())); + } catch (MalformedURLException e) { + grafanaDatasourceURL.completeExceptionally( + new HttpException( + HttpStatus.SC_BAD_GATEWAY, + String.format( + "Configuration property %s=%s is not a valid URL", + ConfigProperties.GRAFANA_DATASOURCE_URL, + grafanaDatasourceURLProperty.get()))); + return; + } + } + public ActiveRecording startRecording( Target target, IConstrainedMap recordingOptions, @@ -586,6 +638,8 @@ public String archivedRecordingKey(Pair pair) { } public String encodedKey(String jvmId, String filename) { + Objects.requireNonNull(jvmId); + Objects.requireNonNull(filename); return base64Url.encodeAsString( (archivedRecordingKey(jvmId, filename)).getBytes(StandardCharsets.UTF_8)); } @@ -751,9 +805,8 @@ private Metadata taggingToMetadata(List tagSet) { return new Metadata(labels, expiry); } - // jfr-datasource handling - public Response uploadToJFRDatasource(long targetEntityId, long remoteId, URL uploadUrl) - throws Exception { + @Blocking + public Uni uploadToJFRDatasource(long targetEntityId, long remoteId) throws Exception { Target target = Target.getTargetById(targetEntityId); Objects.requireNonNull(target, "Target from targetId not found"); ActiveRecording recording = target.getRecordingById(remoteId); @@ -769,37 +822,60 @@ public Response uploadToJFRDatasource(long targetEntityId, long remoteId, URL up target.targetId(), recording.name)); }); + return uploadToJFRDatasource(recordingPath); + } + + @Blocking + public Uni uploadToJFRDatasource(Pair key) throws Exception { + Objects.requireNonNull(key); + Objects.requireNonNull(key.getKey()); + Objects.requireNonNull(key.getValue()); + GetObjectRequest getRequest = + GetObjectRequest.builder() + .bucket(archiveBucket) + .key(archivedRecordingKey(key)) + .build(); + + Path recordingPath = fs.createTempFile(null, null); + // the S3 client will create the file at this path, we just need to get a fresh temp file + // path but one that does not yet exist + fs.deleteIfExists(recordingPath); + + storage.getObject(getRequest, recordingPath); + + return uploadToJFRDatasource(recordingPath); + } + + private Uni uploadToJFRDatasource(Path recordingPath) + throws URISyntaxException, InterruptedException, ExecutionException { MultipartForm form = MultipartForm.create() .binaryFileUpload( "file", DATASOURCE_FILENAME, recordingPath.toString(), JFR_MIME); - try { - ResponseBuilder builder = new ResponseBuilderImpl(); - var asyncRequest = - webClient - .postAbs(uploadUrl.toURI().resolve("/load").normalize().toString()) - .addQueryParam("overwrite", "true") - .timeout(connectionFailedTimeout.toMillis()) - .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); - } + var asyncRequest = + webClient + .postAbs( + grafanaDatasourceURL + .get() + .toURI() + .resolve("/load") + .normalize() + .toString()) + .addQueryParam("overwrite", "true") + .timeout(connectionFailedTimeout.toMillis()) + .sendMultipartForm(form); + return asyncRequest + .onItem() + .transform(HttpResponse::bodyAsString) + .eventually( + () -> { + try { + fs.deleteIfExists(recordingPath); + } catch (IOException e) { + logger.warn(e); + } + }); } Optional getRecordingCopyPath( diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java index 0d3994a90..0760ffe7f 100644 --- a/src/main/java/io/cryostat/recordings/Recordings.java +++ b/src/main/java/io/cryostat/recordings/Recordings.java @@ -16,10 +16,8 @@ package io.cryostat.recordings; import java.io.IOException; -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.time.Instant; @@ -88,8 +86,6 @@ 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.apache.hc.core5.http.HttpStatus; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.RestForm; @@ -820,7 +816,8 @@ static void safeCloseRecording(JFRConnection conn, IRecordingDescriptor rec, Log @Blocking @Path("/api/v1/targets/{connectUrl}/recordings/{recordingName}/upload") @RolesAllowed("write") - public Response uploadToGrafanaV1(@RestPath URI connectUrl, @RestPath String recordingName) { + public Response uploadActiveToGrafanaV1( + @RestPath URI connectUrl, @RestPath String recordingName) { Target target = Target.getTargetByConnectUrl(connectUrl); long remoteId = target.activeRecordings.stream() @@ -841,31 +838,42 @@ public Response uploadToGrafanaV1(@RestPath URI connectUrl, @RestPath String rec @Blocking @Path("/api/v3/targets/{targetId}/recordings/{remoteId}/upload") @RolesAllowed("write") - public Response uploadToGrafana(@RestPath long targetId, @RestPath long remoteId) + public Uni uploadActiveToGrafana(@RestPath long targetId, @RestPath long remoteId) throws Exception { - try { - URL uploadUrl = - new URL( - grafanaDatasourceURL.orElseThrow( - () -> - new HttpException( - HttpStatus.SC_BAD_GATEWAY, - "GRAFANA_DATASOURCE_URL environment variable" - + " does not exist"))); - boolean isValidUploadUrl = - new UrlValidator(UrlValidator.ALLOW_LOCAL_URLS).isValid(uploadUrl.toString()); - if (!isValidUploadUrl) { - throw new HttpException( - HttpStatus.SC_BAD_GATEWAY, - String.format( - "$%s=%s is an invalid datasource URL", - ConfigProperties.GRAFANA_DATASOURCE_URL, uploadUrl.toString())); - } + return recordingHelper.uploadToJFRDatasource(targetId, remoteId); + } - return recordingHelper.uploadToJFRDatasource(targetId, remoteId, uploadUrl); - } catch (MalformedURLException e) { - throw new HttpException(HttpStatus.SC_BAD_GATEWAY, e); + @POST + @Path("/api/beta/fs/recordings/{jvmId}/{filename}/upload") + @RolesAllowed("write") + public Response uploadArchivedToGrafanaBeta(@RestPath String jvmId, @RestPath String filename) + throws Exception { + return Response.status(RestResponse.Status.PERMANENT_REDIRECT) + .location( + URI.create( + String.format( + "/api/v3/grafana/%s", + recordingHelper.encodedKey(jvmId, filename)))) + .build(); + } + + @POST + @Blocking + @Path("/api/v3/grafana/{encodedKey}") + @RolesAllowed("write") + public Uni uploadArchivedToGrafana(@RestPath String encodedKey) throws Exception { + var key = recordingHelper.decodedKey(encodedKey); + var found = + recordingHelper.listArchivedRecordingObjects().stream() + .anyMatch( + o -> + Objects.equals( + o.key(), + recordingHelper.archivedRecordingKey(key))); + if (!found) { + throw new NotFoundException(); } + return recordingHelper.uploadToJFRDatasource(key); } @GET