From 82f8802cf871037a97253ca0d07191acb810df93 Mon Sep 17 00:00:00 2001 From: Andrew Azores Date: Wed, 22 Nov 2023 12:49:16 -0500 Subject: [PATCH] feat(recordings): implement JFR snapshot (#178) --- pom.xml | 2 +- src/main/java/io/cryostat/V2Response.java | 1 - .../cryostat/recordings/RecordingHelper.java | 73 +++ .../io/cryostat/recordings/Recordings.java | 84 +++- src/test/java/itest/SnapshotIT.java | 431 ------------------ src/test/java/itest/SnapshotTest.java | 394 ++++++++++++++++ ...ntsGetIT.java => TargetEventsGetTest.java} | 6 +- .../java/itest/bases/StandardSelfTest.java | 2 +- src/test/java/itest/util/Utils.java | 2 +- 9 files changed, 540 insertions(+), 455 deletions(-) delete mode 100644 src/test/java/itest/SnapshotIT.java create mode 100644 src/test/java/itest/SnapshotTest.java rename src/test/java/itest/{TargetEventsGetIT.java => TargetEventsGetTest.java} (98%) diff --git a/pom.xml b/pom.xml index 7227a4c84..78c1ced4f 100644 --- a/pom.xml +++ b/pom.xml @@ -52,7 +52,7 @@ 1.18.1 4.3 3.2.2 - 5 + 2 ${surefire-plugin.version} ${surefire.rerunFailingTestsCount} 2.10.1 diff --git a/src/main/java/io/cryostat/V2Response.java b/src/main/java/io/cryostat/V2Response.java index 9ee241cda..f5db1c711 100644 --- a/src/main/java/io/cryostat/V2Response.java +++ b/src/main/java/io/cryostat/V2Response.java @@ -34,4 +34,3 @@ public record Meta(String type, String status) { public record Data(@Nullable Object result) {} } - // {"meta":{"type":"application/json","status":"OK"},"data":{"result":{"name":"Test_Rule","description":"This is a rule for testing","matchExpression":"target.alias=='io.cryostat.Cryostat'","eventSpecifier":"template=Continuous,type=TARGET","archivalPeriodSeconds":30,"preservedArchives":1,"maxAgeSeconds":30,"maxSizeBytes":-1}}} diff --git a/src/main/java/io/cryostat/recordings/RecordingHelper.java b/src/main/java/io/cryostat/recordings/RecordingHelper.java index dd6a3be5d..fef3a5c7d 100644 --- a/src/main/java/io/cryostat/recordings/RecordingHelper.java +++ b/src/main/java/io/cryostat/recordings/RecordingHelper.java @@ -195,6 +195,72 @@ public ActiveRecording startRecording( return recording; } + public ActiveRecording createSnapshot(Target target, JFRConnection connection) + throws Exception { + IRecordingDescriptor desc = connection.getService().getSnapshotRecording(); + + String rename = String.format("%s-%d", desc.getName().toLowerCase(), desc.getId()); + + RecordingOptionsBuilder recordingOptionsBuilder = + recordingOptionsBuilderFactory.create(connection.getService()); + recordingOptionsBuilder.name(rename); + + connection.getService().updateRecordingOptions(desc, recordingOptionsBuilder.build()); + + Optional updatedDescriptor = getDescriptorByName(connection, rename); + + if (updatedDescriptor.isEmpty()) { + throw new IllegalStateException( + "The most recent snapshot of the recording cannot be" + + " found after renaming."); + } + + desc = updatedDescriptor.get(); + + try (InputStream snapshot = remoteRecordingStreamFactory.open(connection, target, desc)) { + if (!snapshotIsReadable(target, snapshot)) { + connection.getService().close(desc); + throw new SnapshotCreationException( + "Snapshot was not readable - are there any source recordings?"); + } + } + + ActiveRecording recording = + ActiveRecording.from( + target, + desc, + new Metadata( + Map.of( + "jvmId", + target.jvmId, + "connectUrl", + target.connectUrl.toString()))); + recording.persist(); + + target.activeRecordings.add(recording); + target.persist(); + + bus.publish( + MessagingServer.class.getName(), + new Notification( + "SnapshotCreated", new RecordingEvent(target.connectUrl, recording))); + + return recording; + } + + private boolean snapshotIsReadable(Target target, InputStream snapshot) throws IOException { + if (!connectionManager.markConnectionInUse(target)) { + throw new IOException( + "Target connection unexpectedly closed while streaming recording"); + } + + try { + return snapshot.read() != -1; + } catch (IOException e) { + return false; + } + } + private boolean shouldRestartRecording( RecordingReplace replace, RecordingState state, String recordingName) throws BadRequestException { @@ -212,6 +278,7 @@ private boolean shouldRestartRecording( public LinkedRecordingDescriptor toExternalForm(ActiveRecording recording) { return new LinkedRecordingDescriptor( + recording.id, recording.remoteId, recording.state, recording.duration, @@ -769,4 +836,10 @@ public RecordingNotFoundException(Pair key) { this(key.getLeft(), key.getRight()); } } + + static class SnapshotCreationException extends Exception { + public SnapshotCreationException(String message) { + super(message); + } + } } diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java index 843f4bf39..9cc150323 100644 --- a/src/main/java/io/cryostat/recordings/Recordings.java +++ b/src/main/java/io/cryostat/recordings/Recordings.java @@ -41,11 +41,13 @@ import io.cryostat.ConfigProperties; import io.cryostat.Producers; +import io.cryostat.V2Response; import io.cryostat.core.net.JFRConnection; import io.cryostat.core.sys.Clock; import io.cryostat.core.templates.TemplateType; import io.cryostat.recordings.ActiveRecording.Listener.RecordingEvent; import io.cryostat.recordings.RecordingHelper.RecordingReplace; +import io.cryostat.recordings.RecordingHelper.SnapshotCreationException; import io.cryostat.targets.Target; import io.cryostat.targets.TargetConnectionManager; import io.cryostat.util.HttpStatusCodeIdentifier; @@ -491,6 +493,69 @@ public Response patchV1(@RestPath URI connectUrl, @RestPath String recordingName .build(); } + @POST + @Transactional + @Blocking + @Path("/api/v1/targets/{connectUrl}/snapshot") + @RolesAllowed("write") + public Response createSnapshotV1(@RestPath URI connectUrl) throws Exception { + Target target = Target.getTargetByConnectUrl(connectUrl); + try { + ActiveRecording recording = + connectionManager.executeConnectedTask( + target, + connection -> recordingHelper.createSnapshot(target, connection)); + return Response.status(Response.Status.OK).entity(recording.name).build(); + } catch (SnapshotCreationException sce) { + return Response.status(Response.Status.ACCEPTED).build(); + } + } + + @POST + @Transactional + @Blocking + @Path("/api/v2/targets/{connectUrl}/snapshot") + @RolesAllowed("write") + public Response createSnapshotV2(@RestPath URI connectUrl) throws Exception { + Target target = Target.getTargetByConnectUrl(connectUrl); + try { + ActiveRecording recording = + connectionManager.executeConnectedTask( + target, + connection -> recordingHelper.createSnapshot(target, connection)); + return Response.status(Response.Status.CREATED) + .entity( + V2Response.json( + recordingHelper.toExternalForm(recording), + RestResponse.Status.CREATED.toString())) + .build(); + } catch (SnapshotCreationException sce) { + return Response.status(Response.Status.ACCEPTED) + .entity(V2Response.json(null, RestResponse.Status.ACCEPTED.toString())) + .build(); + } + } + + @POST + @Transactional + @Blocking + @Path("/api/v3/targets/{id}/snapshot") + @RolesAllowed("write") + public Response createSnapshot(@RestPath long id) throws Exception { + Target target = Target.find("id", id).singleResult(); + try { + ActiveRecording recording = + connectionManager.executeConnectedTask( + target, + connection -> recordingHelper.createSnapshot(target, connection)); + return Response.status(Response.Status.OK) + .entity(recordingHelper.toExternalForm(recording)) + .build(); + } catch (SnapshotCreationException sce) { + return Response.status(Response.Status.ACCEPTED).build(); + } + } + @POST @Transactional @Blocking @@ -576,7 +641,7 @@ public Response createRecording( } return Response.status(Response.Status.CREATED) - .entity(recordingHelper.toExternalForm(recording).toString()) + .entity(recordingHelper.toExternalForm(recording)) .build(); } @@ -873,6 +938,7 @@ private static Long getNumericOption( public record LinkedRecordingDescriptor( long id, + long remoteId, RecordingState state, long duration, long startTime, @@ -891,22 +957,6 @@ public record LinkedRecordingDescriptor( Objects.requireNonNull(reportUrl); Objects.requireNonNull(metadata); } - - public static LinkedRecordingDescriptor from(ActiveRecording recording) { - return new LinkedRecordingDescriptor( - recording.remoteId, - recording.state, - recording.duration, - recording.startTime, - recording.continuous, - recording.toDisk, - recording.maxSize, - recording.maxAge, - recording.name, - "TODO", - "TODO", - recording.metadata); - } } public record ArchivedRecording( diff --git a/src/test/java/itest/SnapshotIT.java b/src/test/java/itest/SnapshotIT.java deleted file mode 100644 index b4771eb95..000000000 --- a/src/test/java/itest/SnapshotIT.java +++ /dev/null @@ -1,431 +0,0 @@ -/* - * 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 itest; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.cryostat.util.HttpMimeType; - -import io.quarkus.test.junit.QuarkusIntegrationTest; -import io.vertx.core.MultiMap; -import io.vertx.core.http.HttpHeaders; -import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.web.handler.HttpException; -import itest.bases.StandardSelfTest; -import itest.util.ITestCleanupFailedException; -import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -@QuarkusIntegrationTest -@Disabled("TODO") -public class SnapshotIT extends StandardSelfTest { - static final String TEST_RECORDING_NAME = "someRecording"; - static final String TARGET_REQ_URL = - String.format("/api/v1/targets%s", getSelfReferenceConnectUrl()); - static final String V2_SNAPSHOT_REQ_URL = - String.format("/api/v2/targets%s", getSelfReferenceConnectUrl()); - static final Pattern SNAPSHOT_NAME_PATTERN = Pattern.compile("^snapshot-[0-9]+$"); - static final Pattern TIMESTAMP_PATTERN = Pattern.compile("^[0-9]+$"); - - @AfterAll - static void verifyRecordingsCleanedUp() throws Exception { - CompletableFuture listRespFuture1 = new CompletableFuture<>(); - webClient - .get(String.format("%s/recordings", TARGET_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, listRespFuture1)) { - listRespFuture1.complete(ar.result().bodyAsJsonArray()); - } - }); - JsonArray listResp = listRespFuture1.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); - Assertions.assertTrue(listResp.isEmpty()); - } - - @Test - void testPostV1ShouldCreateSnapshot() throws Exception { - - CompletableFuture snapshotName = new CompletableFuture<>(); - - try { - // Create a recording - CompletableFuture recordResponse = new CompletableFuture<>(); - MultiMap form = MultiMap.caseInsensitiveMultiMap(); - form.add("recordingName", TEST_RECORDING_NAME); - form.add("duration", "5"); - form.add("events", "template=ALL"); - - webClient - .post(String.format("%s/recordings", TARGET_REQ_URL)) - .sendForm( - form, - ar -> { - if (assertRequestStatus(ar, recordResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(201)); - recordResponse.complete(null); - } - }); - - recordResponse.get(); - - // Create a snapshot recording of all events at that time - webClient - .post(String.format("%s/snapshot", TARGET_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, snapshotName)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(200)); - MatcherAssert.assertThat( - ar.result() - .getHeader(HttpHeaders.CONTENT_TYPE.toString()), - Matchers.equalTo(HttpMimeType.PLAINTEXT.mime())); - snapshotName.complete(ar.result().bodyAsString()); - } - }); - - MatcherAssert.assertThat( - snapshotName.get(), Matchers.matchesPattern(SNAPSHOT_NAME_PATTERN)); - - } finally { - // Clean up recording and snapshot - CompletableFuture deleteRecordingResponse = new CompletableFuture<>(); - webClient - .delete(String.format("%s/recordings/%s", TARGET_REQ_URL, TEST_RECORDING_NAME)) - .send( - ar -> { - if (assertRequestStatus(ar, deleteRecordingResponse)) { - deleteRecordingResponse.complete( - ar.result().bodyAsJsonObject()); - } - }); - - try { - deleteRecordingResponse.get(); - } catch (InterruptedException | ExecutionException e) { - logger.error( - new ITestCleanupFailedException( - String.format( - "Failed to delete target recording %s", - TEST_RECORDING_NAME), - e)); - } - - CompletableFuture deleteSnapshotResponse = new CompletableFuture<>(); - - webClient - .delete(String.format("%s/recordings/%s", TARGET_REQ_URL, snapshotName.get())) - .send( - ar -> { - if (assertRequestStatus(ar, deleteSnapshotResponse)) { - deleteSnapshotResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - - try { - deleteSnapshotResponse.get(); - } catch (InterruptedException | ExecutionException e) { - logger.error( - new ITestCleanupFailedException( - String.format("Failed to delete snapshot %s", snapshotName.get()), - e)); - } - } - } - - @Test - void testPostV1ShouldHandleEmptySnapshot() throws Exception { - CompletableFuture result = new CompletableFuture<>(); - - try { - // Create an empty snapshot recording (no active recordings present) - webClient - .post(String.format("%s/snapshot", TARGET_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, result)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(202)); - result.complete(null); - } - }); - result.get(); - } finally { - // The empty snapshot should've been deleted (i.e. there should be no recordings - // present) - CompletableFuture listRespFuture = new CompletableFuture<>(); - webClient - .get(String.format("%s/recordings", TARGET_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, listRespFuture)) { - listRespFuture.complete(ar.result().bodyAsJsonArray()); - } - }); - JsonArray listResp = listRespFuture.get(); - Assertions.assertTrue(listResp.isEmpty()); - } - } - - @Test - void testPostV1SnapshotThrowsWithNonExistentTarget() throws Exception { - - CompletableFuture snapshotResponse = new CompletableFuture<>(); - webClient - .post("/api/v1/targets/notFound:9000/snapshot") - .send( - ar -> { - assertRequestStatus(ar, snapshotResponse); - }); - ExecutionException ex = - Assertions.assertThrows(ExecutionException.class, () -> snapshotResponse.get()); - MatcherAssert.assertThat( - ((HttpException) ex.getCause()).getStatusCode(), Matchers.equalTo(404)); - MatcherAssert.assertThat(ex.getCause().getMessage(), Matchers.equalTo("Not Found")); - } - - @Test - void testPostV2ShouldCreateSnapshot() throws Exception { - - CompletableFuture snapshotName = new CompletableFuture<>(); - - try { - // Create a recording - CompletableFuture recordResponse = new CompletableFuture<>(); - - MultiMap form = MultiMap.caseInsensitiveMultiMap(); - form.add("recordingName", TEST_RECORDING_NAME); - form.add("duration", "5"); - form.add("events", "template=ALL"); - - webClient - .post(String.format("%s/recordings", TARGET_REQ_URL)) - .sendForm( - form, - ar -> { - if (assertRequestStatus(ar, recordResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(201)); - recordResponse.complete(null); - } - }); - - recordResponse.get(); - - // Create a snapshot recording of all events at that time - CompletableFuture createResponse = new CompletableFuture<>(); - webClient - .post(String.format("%s/snapshot", V2_SNAPSHOT_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, createResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(201)); - MatcherAssert.assertThat( - ar.result() - .getHeader(HttpHeaders.CONTENT_TYPE.toString()), - Matchers.equalTo(HttpMimeType.PLAINTEXT.mime())); - createResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - - snapshotName.complete( - createResponse - .get() - .getJsonObject("data") - .getJsonObject("result") - .getString("name")); - - final Long startTime = - createResponse - .get() - .getJsonObject("data") - .getJsonObject("result") - .getLong("startTime"); - - final Long duration = - createResponse - .get() - .getJsonObject("data") - .getJsonObject("result") - .getLong("duration"); - - // Extract id from snapshot name for validation - Pattern idPattern = Pattern.compile("[0-9]+"); - Matcher idMatcher = idPattern.matcher(snapshotName.get()); - idMatcher.find(); - final Integer snapshotId = Integer.parseInt(idMatcher.group()); - - final String expectedDownloadUrl = - String.format( - "http://localhost:8181%s/recordings/%s", - TARGET_REQ_URL, snapshotName.get()); - final String expectedReportUrl = - String.format( - "http://localhost:8181%s/reports/%s", - TARGET_REQ_URL, snapshotName.get()); - - JsonObject expectedCreateResponse = - new JsonObject( - Map.of( - "meta", - Map.of( - "type", - HttpMimeType.PLAINTEXT.mime(), - "status", - "Created"), - "data", - Map.ofEntries( - Map.entry( - "result", - Map.ofEntries( - Map.entry( - "downloadUrl", - expectedDownloadUrl), - Map.entry( - "reportUrl", - expectedReportUrl), - Map.entry("id", snapshotId), - Map.entry( - "name", - snapshotName.get()), - Map.entry("state", "STOPPED"), - Map.entry( - "startTime", startTime), - Map.entry("duration", duration), - Map.entry("continuous", true), - Map.entry("toDisk", true), - Map.entry("maxSize", 0), - Map.entry("maxAge", 0), - Map.entry( - "archiveOnStop", false), - Map.entry( - "metadata", - Map.of( - "labels", - Map.of()))))))); - - MatcherAssert.assertThat( - createResponse.get(), Matchers.equalToObject(expectedCreateResponse)); - - } finally { - // Clean up recording and snapshot - CompletableFuture deleteRecordingResponse = new CompletableFuture<>(); - webClient - .delete(String.format("%s/recordings/%s", TARGET_REQ_URL, TEST_RECORDING_NAME)) - .send( - ar -> { - if (assertRequestStatus(ar, deleteRecordingResponse)) { - deleteRecordingResponse.complete( - ar.result().bodyAsJsonObject()); - } - }); - - try { - deleteRecordingResponse.get(); - } catch (InterruptedException | ExecutionException e) { - logger.error( - new ITestCleanupFailedException( - String.format( - "Failed to delete target recording %s", - TEST_RECORDING_NAME), - e)); - } - - CompletableFuture deleteSnapshotResponse = new CompletableFuture<>(); - - webClient - .delete(String.format("%s/recordings/%s", TARGET_REQ_URL, snapshotName.get())) - .send( - ar -> { - if (assertRequestStatus(ar, deleteSnapshotResponse)) { - deleteSnapshotResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - - try { - deleteSnapshotResponse.get(); - } catch (InterruptedException | ExecutionException e) { - logger.error( - new ITestCleanupFailedException( - String.format("Failed to delete snapshot %s", snapshotName.get()), - e)); - } - } - } - - @Test - void testPostV2ShouldHandleEmptySnapshot() throws Exception { - CompletableFuture result = new CompletableFuture<>(); - - try { - // Create an empty snapshot recording (no active recordings present) - webClient - .post(String.format("%s/snapshot", V2_SNAPSHOT_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, result)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(202)); - result.complete(null); - } - }); - result.get(); - } finally { - // The empty snapshot should've been deleted (i.e. there should be no recordings - // present) - CompletableFuture listRespFuture = new CompletableFuture<>(); - webClient - .get(String.format("%s/recordings", TARGET_REQ_URL)) - .send( - ar -> { - if (assertRequestStatus(ar, listRespFuture)) { - listRespFuture.complete(ar.result().bodyAsJsonArray()); - } - }); - JsonArray listResp = listRespFuture.get(); - Assertions.assertTrue(listResp.isEmpty()); - } - } - - @Test - void testPostV2SnapshotThrowsWithNonExistentTarget() throws Exception { - - CompletableFuture snapshotName = new CompletableFuture<>(); - webClient - .post("/api/v2/targets/notFound:9000/snapshot") - .send( - ar -> { - assertRequestStatus(ar, snapshotName); - }); - ExecutionException ex = - Assertions.assertThrows(ExecutionException.class, () -> snapshotName.get()); - MatcherAssert.assertThat( - ((HttpException) ex.getCause()).getStatusCode(), Matchers.equalTo(404)); - MatcherAssert.assertThat(ex.getCause().getMessage(), Matchers.equalTo("Not Found")); - } -} diff --git a/src/test/java/itest/SnapshotTest.java b/src/test/java/itest/SnapshotTest.java new file mode 100644 index 000000000..b08a6e41d --- /dev/null +++ b/src/test/java/itest/SnapshotTest.java @@ -0,0 +1,394 @@ +/* + * 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 itest; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import io.quarkus.test.junit.QuarkusTest; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.handler.HttpException; +import itest.bases.StandardSelfTest; +import itest.util.ITestCleanupFailedException; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@QuarkusTest +@TestMethodOrder(OrderAnnotation.class) +public class SnapshotTest extends StandardSelfTest { + + static final String TEST_RECORDING_NAME = "someRecording"; + static final Pattern SNAPSHOT_NAME_PATTERN = Pattern.compile("^snapshot-[0-9]+$"); + + String v1RequestUrl() { + return String.format("/api/v1/targets/%s", getSelfReferenceConnectUrlEncoded()); + } + + String v2RequestUrl() { + return String.format("/api/v2/targets/%s", getSelfReferenceConnectUrlEncoded()); + } + + @Test + @Order(1) + void testPostV1ShouldHandleEmptySnapshot() throws Exception { + // precondition, there should be no recordings before we start + CompletableFuture preListRespFuture = new CompletableFuture<>(); + webClient + .get(String.format("%s/recordings", v1RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, preListRespFuture)) { + preListRespFuture.complete(ar.result().bodyAsJsonArray()); + } + }); + JsonArray preListResp = preListRespFuture.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + MatcherAssert.assertThat(preListResp, Matchers.equalTo(new JsonArray())); + + CompletableFuture result = new CompletableFuture<>(); + // Create an empty snapshot recording (no active recordings present) + webClient + .post(String.format("%s/snapshot", v1RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, result)) { + result.complete(ar.result().statusCode()); + } + }); + MatcherAssert.assertThat( + result.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), Matchers.equalTo(202)); + + // The empty snapshot should've been deleted (i.e. there should be no recordings + // present) + CompletableFuture postListRespFuture = new CompletableFuture<>(); + webClient + .get(String.format("%s/recordings", v1RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, postListRespFuture)) { + postListRespFuture.complete(ar.result().bodyAsJsonArray()); + } + }); + JsonArray postListResp = postListRespFuture.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + MatcherAssert.assertThat(postListResp, Matchers.equalTo(new JsonArray())); + } + + @Test + @Order(2) + void testPostV2ShouldHandleEmptySnapshot() throws Exception { + // precondition, there should be no recordings before we start + CompletableFuture preListRespFuture = new CompletableFuture<>(); + webClient + .get(String.format("%s/recordings", v1RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, preListRespFuture)) { + preListRespFuture.complete(ar.result().bodyAsJsonArray()); + } + }); + JsonArray preListResp = preListRespFuture.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + MatcherAssert.assertThat(preListResp, Matchers.equalTo(new JsonArray())); + + CompletableFuture result = new CompletableFuture<>(); + // Create an empty snapshot recording (no active recordings present) + webClient + .post(String.format("%s/snapshot", v2RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, result)) { + result.complete(ar.result().statusCode()); + } + }); + MatcherAssert.assertThat( + result.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), Matchers.equalTo(202)); + + // The empty snapshot should've been deleted (i.e. there should be no recordings + // present) + CompletableFuture postListRespFuture = new CompletableFuture<>(); + webClient + .get(String.format("%s/recordings", v1RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, postListRespFuture)) { + postListRespFuture.complete(ar.result().bodyAsJsonArray()); + } + }); + JsonArray postListResp = postListRespFuture.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + MatcherAssert.assertThat(postListResp, Matchers.equalTo(new JsonArray())); + } + + @Test + @Order(3) + void testPostV1ShouldCreateSnapshot() throws Exception { + CompletableFuture snapshotName = new CompletableFuture<>(); + + try { + // Create a recording + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "5"); + form.add("events", "template=ALL"); + webClient + .extensions() + .post(String.format("%s/recordings", v1RequestUrl()), true, form, 5); + + // Create a snapshot recording of all events at that time + webClient + .post(String.format("%s/snapshot", v1RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, snapshotName)) { + MatcherAssert.assertThat( + ar.result().statusCode(), Matchers.equalTo(200)); + MatcherAssert.assertThat( + ar.result() + .getHeader(HttpHeaders.CONTENT_TYPE.toString()), + Matchers.equalTo("text/plain;charset=UTF-8")); + snapshotName.complete(ar.result().bodyAsString()); + } + }); + + MatcherAssert.assertThat( + snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), + Matchers.matchesPattern(SNAPSHOT_NAME_PATTERN)); + + } finally { + // Clean up recording and snapshot + CompletableFuture deleteRecordingResponse = new CompletableFuture<>(); + webClient + .delete(String.format("%s/recordings/%s", v1RequestUrl(), TEST_RECORDING_NAME)) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, deleteRecordingResponse)) { + deleteRecordingResponse.complete( + ar.result().bodyAsJsonObject()); + } + }); + + try { + deleteRecordingResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException e) { + throw new ITestCleanupFailedException( + String.format("Failed to delete target recording %s", TEST_RECORDING_NAME), + e); + } + + CompletableFuture deleteSnapshotResponse = new CompletableFuture<>(); + + webClient + .delete( + String.format( + "%s/recordings/%s", + v1RequestUrl(), + snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS))) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, deleteSnapshotResponse)) { + deleteSnapshotResponse.complete(ar.result().bodyAsJsonObject()); + } + }); + + try { + deleteSnapshotResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException e) { + throw new ITestCleanupFailedException( + String.format( + "Failed to delete snapshot %s", + snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)), + e); + } + } + } + + @Test + @Order(4) + void testPostV1SnapshotThrowsWithNonExistentTarget() throws Exception { + + CompletableFuture snapshotResponse = new CompletableFuture<>(); + webClient + .post("/api/v1/targets/notFound%2F9000/snapshot") + .basicAuthentication("user", "pass") + .send( + ar -> { + assertRequestStatus(ar, snapshotResponse); + }); + ExecutionException ex = + Assertions.assertThrows( + ExecutionException.class, + () -> snapshotResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + MatcherAssert.assertThat( + ((HttpException) ex.getCause()).getStatusCode(), Matchers.equalTo(404)); + MatcherAssert.assertThat(ex.getCause().getMessage(), Matchers.equalTo("Not Found")); + } + + @Test + @Order(5) + void testPostV2ShouldCreateSnapshot() throws Exception { + CompletableFuture snapshotName = new CompletableFuture<>(); + + try { + // Create a recording + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "5"); + form.add("events", "template=ALL"); + webClient + .extensions() + .post(String.format("%s/recordings", v1RequestUrl()), true, form, 5); + + // Create a snapshot recording of all events at that time + CompletableFuture createResponse = new CompletableFuture<>(); + webClient + .post(String.format("%s/snapshot", v2RequestUrl())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, createResponse)) { + MatcherAssert.assertThat( + ar.result().statusCode(), Matchers.equalTo(201)); + MatcherAssert.assertThat( + ar.result() + .getHeader(HttpHeaders.CONTENT_TYPE.toString()), + Matchers.equalTo("application/json;charset=UTF-8")); + createResponse.complete(ar.result().bodyAsJsonObject()); + } + }); + + snapshotName.complete( + createResponse + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .getJsonObject("data") + .getJsonObject("result") + .getString("name")); + + JsonObject json = createResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + MatcherAssert.assertThat( + json.getJsonObject("meta"), + Matchers.equalTo( + new JsonObject( + Map.of("type", "application/json", "status", "Created")))); + MatcherAssert.assertThat(json.getMap(), Matchers.hasKey("data")); + MatcherAssert.assertThat( + json.getJsonObject("data").getMap(), Matchers.hasKey("result")); + JsonObject result = json.getJsonObject("data").getJsonObject("result"); + MatcherAssert.assertThat(result.getString("state"), Matchers.equalTo("STOPPED")); + MatcherAssert.assertThat( + result.getLong("startTime"), + Matchers.lessThanOrEqualTo(Instant.now().toEpochMilli())); + MatcherAssert.assertThat( + result.getString("name"), + Matchers.equalTo(snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS))); + MatcherAssert.assertThat(result.getLong("id"), Matchers.greaterThan(0L)); + MatcherAssert.assertThat( + result.getString("downloadUrl"), + Matchers.equalTo("/api/v3/activedownload/" + result.getLong("id"))); + MatcherAssert.assertThat( + result.getString("reportUrl"), + Matchers.equalTo("/api/v3/targets/1/reports/" + result.getLong("remoteId"))); + MatcherAssert.assertThat(result.getLong("expiry"), Matchers.nullValue()); + + } finally { + // Clean up recording and snapshot + CompletableFuture deleteRecordingResponse = new CompletableFuture<>(); + webClient + .delete(String.format("%s/recordings/%s", v1RequestUrl(), TEST_RECORDING_NAME)) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, deleteRecordingResponse)) { + deleteRecordingResponse.complete( + ar.result().bodyAsJsonObject()); + } + }); + + try { + deleteRecordingResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException e) { + throw new ITestCleanupFailedException( + String.format("Failed to delete target recording %s", TEST_RECORDING_NAME), + e); + } + + CompletableFuture deleteSnapshotResponse = new CompletableFuture<>(); + + webClient + .delete( + String.format( + "%s/recordings/%s", + v1RequestUrl(), + snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS))) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, deleteSnapshotResponse)) { + deleteSnapshotResponse.complete(ar.result().bodyAsJsonObject()); + } + }); + + try { + deleteSnapshotResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException e) { + throw new ITestCleanupFailedException( + String.format( + "Failed to delete snapshot %s", + snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)), + e); + } + } + } + + @Test + @Order(6) + void testPostV2SnapshotThrowsWithNonExistentTarget() throws Exception { + + CompletableFuture snapshotName = new CompletableFuture<>(); + webClient + .post("/api/v2/targets/notFound:9000/snapshot") + .basicAuthentication("user", "pass") + .send( + ar -> { + assertRequestStatus(ar, snapshotName); + }); + ExecutionException ex = + Assertions.assertThrows( + ExecutionException.class, + () -> snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + MatcherAssert.assertThat( + ((HttpException) ex.getCause()).getStatusCode(), Matchers.equalTo(404)); + MatcherAssert.assertThat(ex.getCause().getMessage(), Matchers.equalTo("Not Found")); + } +} diff --git a/src/test/java/itest/TargetEventsGetIT.java b/src/test/java/itest/TargetEventsGetTest.java similarity index 98% rename from src/test/java/itest/TargetEventsGetIT.java rename to src/test/java/itest/TargetEventsGetTest.java index 31cc9a41b..3007511f8 100644 --- a/src/test/java/itest/TargetEventsGetIT.java +++ b/src/test/java/itest/TargetEventsGetTest.java @@ -23,7 +23,7 @@ import io.cryostat.util.HttpMimeType; -import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.quarkus.test.junit.QuarkusTest; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonObject; @@ -34,8 +34,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -@QuarkusIntegrationTest -public class TargetEventsGetIT extends StandardSelfTest { +@QuarkusTest +public class TargetEventsGetTest extends StandardSelfTest { String eventReqUrl; String searchReqUrl; diff --git a/src/test/java/itest/bases/StandardSelfTest.java b/src/test/java/itest/bases/StandardSelfTest.java index e39e6976d..18f43f04f 100644 --- a/src/test/java/itest/bases/StandardSelfTest.java +++ b/src/test/java/itest/bases/StandardSelfTest.java @@ -58,7 +58,7 @@ public abstract class StandardSelfTest { private static final ExecutorService WORKER = Executors.newCachedThreadPool(); public static final Logger logger = Logger.getLogger(StandardSelfTest.class); public static final ObjectMapper mapper = new ObjectMapper(); - public static final int REQUEST_TIMEOUT_SECONDS = 5; + public static final int REQUEST_TIMEOUT_SECONDS = 15; public static final int DISCOVERY_DEADLINE_SECONDS = 10; public static final TestWebClient webClient = Utils.getWebClient(); public static volatile String selfCustomTargetLocation; diff --git a/src/test/java/itest/util/Utils.java b/src/test/java/itest/util/Utils.java index 2f473945c..3793c9402 100644 --- a/src/test/java/itest/util/Utils.java +++ b/src/test/java/itest/util/Utils.java @@ -138,7 +138,7 @@ public HttpResponse post( }); } if (future.get().statusCode() == 308) { - return post(future.get().getHeader("Location"), true, form, timeout); + return post(future.get().getHeader("Location"), authentication, form, timeout); } return future.get(timeout, TimeUnit.SECONDS); }