Skip to content

Commit

Permalink
feat(grafana): implement endpoint for 'view in grafana' of archived r…
Browse files Browse the repository at this point in the history
…ecordings (#287)
  • Loading branch information
andrewazores authored Feb 16, 2024
1 parent a3bd291 commit afd746f
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 65 deletions.
19 changes: 13 additions & 6 deletions smoketest.bash
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ OPEN_TABS=${OPEN_TABS:-false}

CRYOSTAT_HTTP_PORT=8080
USE_PROXY=${USE_PROXY:-true}
DEPLOY_GRAFANA=false
DEPLOY_GRAFANA=true

display_usage() {
echo "Usage:"
echo -e "\t-h\t\t\t\t\t\tprint this Help text."
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."
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/io/cryostat/ExceptionMappers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -100,4 +104,16 @@ public RestResponse<Object> mapEntityExistsException(EntityExistsException ex) {
.entity(ex.getMessage())
.build();
}

@ServerExceptionMapper
public RestResponse<Void> mapCompletionException(CompletionException ex) throws Throwable {
logger.warn(ex);
throw ExceptionUtils.getRootCause(ex);
}

@ServerExceptionMapper
public RestResponse<Void> mapExecutionException(ExecutionException ex) throws Throwable {
logger.warn(ex);
throw ExceptionUtils.getRootCause(ex);
}
}
140 changes: 108 additions & 32 deletions src/main/java/io/cryostat/recordings/RecordingHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -135,6 +144,49 @@ public class RecordingHelper {
@ConfigProperty(name = ConfigProperties.CONNECTIONS_FAILED_TIMEOUT)
Duration connectionFailedTimeout;

@ConfigProperty(name = ConfigProperties.GRAFANA_DATASOURCE_URL)
Optional<String> grafanaDatasourceURLProperty;

CompletableFuture<URL> 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<String> recordingOptions,
Expand Down Expand Up @@ -586,6 +638,8 @@ public String archivedRecordingKey(Pair<String, String> pair) {
}

public String encodedKey(String jvmId, String filename) {
Objects.requireNonNull(jvmId);
Objects.requireNonNull(filename);
return base64Url.encodeAsString(
(archivedRecordingKey(jvmId, filename)).getBytes(StandardCharsets.UTF_8));
}
Expand Down Expand Up @@ -751,9 +805,8 @@ private Metadata taggingToMetadata(List<Tag> tagSet) {
return new Metadata(labels, expiry);
}

// jfr-datasource handling
public Response uploadToJFRDatasource(long targetEntityId, long remoteId, URL uploadUrl)
throws Exception {
@Blocking
public Uni<String> 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);
Expand All @@ -769,37 +822,60 @@ public Response uploadToJFRDatasource(long targetEntityId, long remoteId, URL up
target.targetId(), recording.name));
});

return uploadToJFRDatasource(recordingPath);
}

@Blocking
public Uni<String> uploadToJFRDatasource(Pair<String, String> 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<String> 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<Path> getRecordingCopyPath(
Expand Down
62 changes: 35 additions & 27 deletions src/main/java/io/cryostat/recordings/Recordings.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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<String> 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<String> 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
Expand Down

0 comments on commit afd746f

Please sign in to comment.