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