diff --git a/pom.xml b/pom.xml
index 1883709a0..ef12b769e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -35,7 +35,6 @@
1.16.0
2.13.0
- 4.5.14
5.2.1
3.13.0
1.7
diff --git a/smoketest.bash b/smoketest.bash
index d53f40d8a..13267307e 100755
--- a/smoketest.bash
+++ b/smoketest.bash
@@ -25,17 +25,17 @@ display_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 [minio|seaweed|cloudserver|localstack]\tS3 implementation to spin up (default \"minio\")."
+ 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-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."
echo -e "\t-X\t\t\t\t\t\tdeploy additional development aid tools."
echo -e "\t-c [podman|docker]\t\t\t\tUse Podman or Docker Container Engine (default \"podman\")."
- echo -e "\t-b\t\t\t\t\t\tOpen a Browser tab for each running service's first mapped port (ex. Cryostat web client, Minio console)"
+ echo -e "\t-b\t\t\t\t\t\tOpen a Browser tab for each running service's first mapped port (ex. auth proxy login, database viewer)"
}
-s3=minio
+s3=seaweed
ce=podman
while getopts "hs:prgtOVXcb" opt; do
case $opt in
@@ -86,7 +86,7 @@ if [ "${USE_PROXY}" = "true" ]; then
CRYOSTAT_HTTP_PORT=8181
GRAFANA_DASHBOARD_EXT_URL=http://localhost:8080/grafana/
else
- FILES+=("${DIR}/smoketest/compose/no_proxy.yml")
+ FILES+=("${DIR}/smoketest/compose/no_proxy.yml" "${DIR}/smoketest/compose/s3_no_proxy.yml")
if [ "${DEPLOY_GRAFANA}" = "true" ]; then
FILES+=("${DIR}/smoketest/compose/grafana_no_proxy.yml")
fi
@@ -96,6 +96,8 @@ export CRYOSTAT_HTTP_PORT
export GRAFANA_DASHBOARD_EXT_URL
s3Manifest="${DIR}/smoketest/compose/s3-${s3}.yml"
+STORAGE_PORT="$(yq '.services.*.expose[0]' "${s3Manifest}" | grep -v null)"
+export STORAGE_PORT
if [ ! -f "${s3Manifest}" ]; then
echo "Unknown S3 selection: ${s3}"
@@ -142,8 +144,10 @@ cleanup() {
docker-compose \
"${CMD[@]}" \
down "${downFlags[@]}"
- ${container_engine} rm proxy_cfg_helper
- ${container_engine} volume rm auth_proxy_cfg
+ ${container_engine} rm proxy_cfg_helper || true
+ ${container_engine} volume rm auth_proxy_cfg || true
+ ${container_engine} rm seaweed_cfg_helper || true
+ ${container_engine} volume rm seaweed_cfg || true
# podman kill hoster || true
truncate -s 0 "${HOSTSFILE}"
for i in "${PIDS[@]}"; do
@@ -157,10 +161,25 @@ cleanup
createProxyCfgVolume() {
"${container_engine}" volume create auth_proxy_cfg
"${container_engine}" container create --name proxy_cfg_helper -v auth_proxy_cfg:/tmp busybox
+ local cfg
+ cfg="$(mktemp)"
+ chmod 644 "${cfg}"
+ envsubst '$STORAGE_PORT' < "${DIR}/smoketest/compose/auth_proxy_alpha_config.yaml" > "${cfg}"
"${container_engine}" cp "${DIR}/smoketest/compose/auth_proxy_htpasswd" proxy_cfg_helper:/tmp/auth_proxy_htpasswd
- "${container_engine}" cp "${DIR}/smoketest/compose/auth_proxy_alpha_config.yaml" proxy_cfg_helper:/tmp/auth_proxy_alpha_config.yaml
+ "${container_engine}" cp "${cfg}" proxy_cfg_helper:/tmp/auth_proxy_alpha_config.yaml
}
-createProxyCfgVolume
+if [ "${USE_PROXY}" = "true" ]; then
+ createProxyCfgVolume
+fi
+
+createSeaweedConfigVolume() {
+ "${container_engine}" volume create seaweed_cfg
+ "${container_engine}" container create --name seaweed_cfg_helper -v seaweed_cfg:/tmp busybox
+ "${container_engine}" cp "${DIR}/smoketest/compose/seaweed_cfg.json" seaweed_cfg_helper:/tmp/seaweed_cfg.json
+}
+if [ "${s3}" = "seaweed" ]; then
+ createSeaweedConfigVolume
+fi
setupUserHosts() {
# FIXME this is broken: it puts the containers' bridge-internal IP addresses
diff --git a/smoketest/compose/auth_proxy_alpha_config.yaml b/smoketest/compose/auth_proxy_alpha_config.yaml
index ba6889ef0..676f55b2b 100644
--- a/smoketest/compose/auth_proxy_alpha_config.yaml
+++ b/smoketest/compose/auth_proxy_alpha_config.yaml
@@ -9,6 +9,12 @@ upstreamConfig:
- id: grafana
path: /grafana/
uri: http://grafana:3000
+ - id: storage
+ path: ^/storage/(.*)$
+ rewriteTarget: /$1
+ uri: http://s3:${STORAGE_PORT}
+ passHostHeader: false
+ proxyWebSockets: false
providers:
- id: dummy
name: Unused - Sign In Below
diff --git a/smoketest/compose/s3-cloudserver.yml b/smoketest/compose/s3-cloudserver.yml
index 9b6618e46..2abeb1039 100644
--- a/smoketest/compose/s3-cloudserver.yml
+++ b/smoketest/compose/s3-cloudserver.yml
@@ -4,6 +4,7 @@ services:
environment:
STORAGE_BUCKETS_ARCHIVES_NAME: archivedrecordings
QUARKUS_S3_ENDPOINT_OVERRIDE: http://s3:8000
+ STORAGE_EXT_URL: http://localhost:8080/storage/
QUARKUS_S3_PATH_STYLE_ACCESS: "true" # needed since compose setup does not support DNS subdomain resolution
QUARKUS_S3_AWS_REGION: us-east-1
QUARKUS_S3_AWS_CREDENTIALS_TYPE: static
@@ -14,8 +15,6 @@ services:
s3:
image: ${CLOUDSERVER_IMAGE:-docker.io/zenko/cloudserver:latest}
hostname: s3
- ports:
- - "8000:8000"
expose:
- "8000"
environment:
diff --git a/smoketest/compose/s3-localstack.yml b/smoketest/compose/s3-localstack.yml
index e33e8d180..8945031b9 100644
--- a/smoketest/compose/s3-localstack.yml
+++ b/smoketest/compose/s3-localstack.yml
@@ -4,6 +4,7 @@ services:
environment:
STORAGE_BUCKETS_ARCHIVES_NAME: archivedrecordings
QUARKUS_S3_ENDPOINT_OVERRIDE: http://s3:4566
+ STORAGE_EXT_URL: http://localhost:8080/storage/
QUARKUS_S3_PATH_STYLE_ACCESS: "true" # needed since compose setup does not support DNS subdomain resolution
QUARKUS_S3_AWS_REGION: us-east-1
QUARKUS_S3_AWS_CREDENTIALS_TYPE: static
@@ -14,8 +15,6 @@ services:
s3:
image: ${LOCALSTACK_IMAGE:-docker.io/localstack/localstack:latest}
hostname: s3
- ports:
- - "4566:4566"
expose:
- "4566"
environment:
diff --git a/smoketest/compose/s3-minio.yml b/smoketest/compose/s3-minio.yml
index 6428238bc..2006e4a97 100644
--- a/smoketest/compose/s3-minio.yml
+++ b/smoketest/compose/s3-minio.yml
@@ -4,6 +4,7 @@ services:
environment:
STORAGE_BUCKETS_ARCHIVES_NAME: archivedrecordings
QUARKUS_S3_ENDPOINT_OVERRIDE: http://s3:9000
+ STORAGE_EXT_URL: http://localhost:8080/storage/
QUARKUS_S3_PATH_STYLE_ACCESS: "true" # needed since compose setup does not support DNS subdomain resolution
QUARKUS_S3_AWS_REGION: us-east-1
QUARKUS_S3_AWS_CREDENTIALS_TYPE: static
@@ -14,12 +15,8 @@ services:
s3:
image: ${MINIO_IMAGE:-docker.io/minio/minio:latest}
hostname: s3
- ports:
- - "9001:9001"
- - "9000:9000"
expose:
- "9000"
- - "9001"
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioroot
diff --git a/smoketest/compose/s3-seaweed.yml b/smoketest/compose/s3-seaweed.yml
index 7bd476e4c..e496a6e68 100644
--- a/smoketest/compose/s3-seaweed.yml
+++ b/smoketest/compose/s3-seaweed.yml
@@ -4,19 +4,24 @@ services:
environment:
STORAGE_BUCKETS_ARCHIVES_NAME: archivedrecordings
QUARKUS_S3_ENDPOINT_OVERRIDE: http://s3:8333
+ STORAGE_EXT_URL: http://localhost:8080/storage/
QUARKUS_S3_PATH_STYLE_ACCESS: "true" # needed since compose setup does not support DNS subdomain resolution
QUARKUS_S3_AWS_REGION: us-east-1
QUARKUS_S3_AWS_CREDENTIALS_TYPE: static
- QUARKUS_S3_AWS_CREDENTIALS_STATIC_PROVIDER_ACCESS_KEY_ID: unused
- QUARKUS_S3_AWS_CREDENTIALS_STATIC_PROVIDER_SECRET_ACCESS_KEY: unused
- AWS_ACCESS_KEY_ID: unused
- AWS_SECRET_ACCESS_KEY: unused
+ QUARKUS_S3_AWS_CREDENTIALS_STATIC_PROVIDER_ACCESS_KEY_ID: access_key
+ QUARKUS_S3_AWS_CREDENTIALS_STATIC_PROVIDER_SECRET_ACCESS_KEY: secret_key
+ AWS_ACCESS_KEY_ID: access_key
+ AWS_SECRET_ACCESS_KEY: secret_key
s3:
image: ${SEAWEEDFS_IMAGE:-docker.io/chrislusf/seaweedfs:latest}
- command: server -s3
hostname: s3
- ports:
- - "8333:8333"
+ command: server -dir=/data -s3 -s3.config=/opt/seaweed_cfg.json
+ environment:
+ IP_BIND: 0.0.0.0
+ WEED_V: '4' # glog logging level
+ volumes:
+ - seaweed_data:/data
+ - seaweed_cfg:/opt
expose:
- "8333"
labels:
@@ -33,3 +38,9 @@ services:
retries: 3
start_period: 30s
timeout: 5s
+
+volumes:
+ seaweed_data:
+ driver: local
+ seaweed_cfg:
+ external: true
diff --git a/smoketest/compose/s3_no_proxy.yml b/smoketest/compose/s3_no_proxy.yml
new file mode 100644
index 000000000..0569cf7f6
--- /dev/null
+++ b/smoketest/compose/s3_no_proxy.yml
@@ -0,0 +1,8 @@
+version: "3"
+services:
+ s3:
+ ports:
+ - "${STORAGE_PORT}:${STORAGE_PORT}"
+ cryostat:
+ environment:
+ STORAGE_EXT_URL: ''
diff --git a/smoketest/compose/seaweed_cfg.json b/smoketest/compose/seaweed_cfg.json
new file mode 100644
index 000000000..1b1d020d3
--- /dev/null
+++ b/smoketest/compose/seaweed_cfg.json
@@ -0,0 +1,28 @@
+{
+ "identities": [
+ {
+ "name": "anonymous",
+ "actions": [
+ "Read"
+ ]
+ },
+ {
+ "name": "cryostat",
+ "credentials": [
+ {
+ "accessKey": "access_key",
+ "secretKey": "secret_key"
+ }
+ ],
+ "actions": [
+ "Admin",
+ "Read",
+ "ReadAcp",
+ "List",
+ "Tagging",
+ "Write",
+ "WriteAcp"
+ ]
+ }
+ ]
+}
diff --git a/src/main/java/io/cryostat/ConfigProperties.java b/src/main/java/io/cryostat/ConfigProperties.java
index 212a9b9d6..7d42fecfd 100644
--- a/src/main/java/io/cryostat/ConfigProperties.java
+++ b/src/main/java/io/cryostat/ConfigProperties.java
@@ -33,4 +33,6 @@ public class ConfigProperties {
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";
+
+ public static final String STORAGE_EXT_URL = "storage-ext.url";
}
diff --git a/src/main/java/io/cryostat/recordings/RecordingHelper.java b/src/main/java/io/cryostat/recordings/RecordingHelper.java
index 58780452e..272088cdb 100644
--- a/src/main/java/io/cryostat/recordings/RecordingHelper.java
+++ b/src/main/java/io/cryostat/recordings/RecordingHelper.java
@@ -432,6 +432,11 @@ public String saveRecording(ActiveRecording recording) throws Exception {
}
public String saveRecording(ActiveRecording recording, Instant expiry) throws Exception {
+ return saveRecording(recording, null, expiry);
+ }
+
+ public String saveRecording(ActiveRecording recording, String savename, Instant expiry)
+ throws Exception {
// AWS object key name guidelines advise characters to avoid (% so we should not pass url
// encoded characters)
String transformedAlias =
@@ -441,6 +446,9 @@ public String saveRecording(ActiveRecording recording, Instant expiry) throws Ex
clock.now().truncatedTo(ChronoUnit.SECONDS).toString().replaceAll("[-:]+", "");
String filename =
String.format("%s_%s_%s.jfr", transformedAlias, recording.name, timestamp);
+ if (StringUtils.isBlank(savename)) {
+ savename = filename;
+ }
int mib = 1024 * 1024;
String key = archivedRecordingKey(recording.target.jvmId, filename);
String multipartId = null;
@@ -453,6 +461,8 @@ public String saveRecording(ActiveRecording recording, Instant expiry) throws Ex
.bucket(archiveBucket)
.key(key)
.contentType(JFR_MIME)
+ .contentDisposition(
+ String.format("attachment; filename=\"%s\"", savename))
.tagging(createActiveRecordingTagging(recording, expiry));
if (expiry != null && expiry.isAfter(Instant.now())) {
builder = builder.expires(expiry);
diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java
index 56422c2ad..74cdd3df2 100644
--- a/src/main/java/io/cryostat/recordings/Recordings.java
+++ b/src/main/java/io/cryostat/recordings/Recordings.java
@@ -20,6 +20,7 @@
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;
import java.util.ArrayList;
@@ -80,6 +81,7 @@
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.HttpHeaders;
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;
@@ -90,6 +92,7 @@
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.RestPath;
+import org.jboss.resteasy.reactive.RestQuery;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import software.amazon.awssdk.core.sync.RequestBody;
@@ -134,6 +137,9 @@ public class Recordings {
@ConfigProperty(name = ConfigProperties.GRAFANA_DATASOURCE_URL)
Optional grafanaDatasourceURL;
+ @ConfigProperty(name = ConfigProperties.STORAGE_EXT_URL)
+ Optional externalStorageUrl;
+
void onStart(@Observes StartupEvent evt) {
boolean exists = false;
try {
@@ -959,15 +965,27 @@ public Response createAndRedirectPresignedDownload(@RestPath long id) throws Exc
if (recording == null) {
throw new NotFoundException();
}
+ String savename = recording.name;
String filename =
recordingHelper.saveRecording(
- recording, Instant.now().plusSeconds(60)); // TODO make expiry configurable
+ recording,
+ savename,
+ Instant.now().plusSeconds(60)); // TODO make expiry configurable
String encodedKey = recordingHelper.encodedKey(recording.target.jvmId, filename);
+ if (!savename.endsWith(".jfr")) {
+ savename += ".jfr";
+ }
return Response.status(RestResponse.Status.PERMANENT_REDIRECT)
.header(
HttpHeaders.CONTENT_DISPOSITION,
- String.format("attachment; filename=\"%s\"", filename))
- .location(URI.create(String.format("/api/v3/download/%s", encodedKey)))
+ String.format("attachment; filename=\"%s\"", savename))
+ .location(
+ URI.create(
+ String.format(
+ "/api/v3/download/%s?f=%s",
+ encodedKey,
+ base64Url.encodeAsString(
+ savename.getBytes(StandardCharsets.UTF_8)))))
.build();
}
@@ -975,7 +993,7 @@ public Response createAndRedirectPresignedDownload(@RestPath long id) throws Exc
@Blocking
@Path("/api/v3/download/{encodedKey}")
@RolesAllowed("read")
- public Response redirectPresignedDownload(@RestPath String encodedKey)
+ public Response redirectPresignedDownload(@RestPath String encodedKey, @RestQuery String f)
throws URISyntaxException {
Pair pair = recordingHelper.decodedKey(encodedKey);
logger.infov("Handling presigned download request for {0}", pair);
@@ -990,9 +1008,32 @@ public Response redirectPresignedDownload(@RestPath String encodedKey)
.getObjectRequest(getRequest)
.build();
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
- return Response.status(RestResponse.Status.PERMANENT_REDIRECT)
- .location(presignedRequest.url().toURI())
- .build();
+ URI uri = presignedRequest.url().toURI();
+ if (externalStorageUrl.isPresent()) {
+ String extUrl = externalStorageUrl.get();
+ if (StringUtils.isNotBlank(extUrl)) {
+ URI extUri = new URI(extUrl);
+ uri =
+ new URI(
+ extUri.getScheme(),
+ extUri.getAuthority(),
+ URI.create(String.format("%s/%s", extUri.getPath(), uri.getPath()))
+ .normalize()
+ .getPath(),
+ uri.getQuery(),
+ uri.getFragment());
+ }
+ }
+ ResponseBuilder response = Response.status(RestResponse.Status.PERMANENT_REDIRECT);
+ if (StringUtils.isNotBlank(f)) {
+ response =
+ response.header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ String.format(
+ "attachment; filename=\"%s\"",
+ new String(base64Url.decode(f), StandardCharsets.UTF_8)));
+ }
+ return response.location(uri).build();
}
private static Map getRecordingOptions(
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index edbba6ee5..b543446bb 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -46,6 +46,7 @@ quarkus.http.filter.static.matches=/static/.+
quarkus.http.filter.static.methods=GET
quarkus.http.filter.static.order=1
+storage-ext.url=
storage.buckets.archives.name=archivedrecordings
storage.buckets.archives.expiration-label=expiration