diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java index 74e5d2cd8..ae45f6e9a 100644 --- a/src/main/java/io/cryostat/recordings/Recordings.java +++ b/src/main/java/io/cryostat/recordings/Recordings.java @@ -15,6 +15,7 @@ */ package io.cryostat.recordings; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; @@ -31,6 +32,8 @@ import java.util.Optional; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.openjdk.jmc.common.unit.IConstrainedMap; import org.openjdk.jmc.common.unit.IOptionDescriptor; @@ -42,6 +45,7 @@ import io.cryostat.ConfigProperties; import io.cryostat.Producers; import io.cryostat.V2Response; +import io.cryostat.core.RecordingOptionsCustomizer; import io.cryostat.core.net.JFRConnection; import io.cryostat.core.sys.Clock; import io.cryostat.core.templates.TemplateType; @@ -110,6 +114,7 @@ public class Recordings { @Inject TargetConnectionManager connectionManager; @Inject EventBus bus; @Inject RecordingOptionsBuilderFactory recordingOptionsBuilderFactory; + @Inject RecordingOptionsCustomizer recordingOptionsCustomizer; @Inject EventOptionsBuilder.Factory eventOptionsBuilderFactory; @Inject Clock clock; @Inject S3Client storage; @@ -464,7 +469,12 @@ public String patch(@RestPath long targetId, @RestPath long remoteId, String bod activeRecording.persist(); return null; case "save": - return recordingHelper.saveRecording(activeRecording); + try { + return recordingHelper.saveRecording(activeRecording); + } catch (IOException ioe) { + logger.warn(ioe); + return null; + } default: throw new BadRequestException(body); } @@ -851,6 +861,82 @@ public Map getRecordingOptions(@RestPath long id) throws Excepti }); } + @PATCH + @Blocking + @Path("/api/v1/targets/{connectUrl}/recordingOptions") + @RolesAllowed("write") + public Response patchRecordingOptionsV1(@RestPath URI connectUrl) { + Target target = Target.getTargetByConnectUrl(connectUrl); + return Response.status(RestResponse.Status.PERMANENT_REDIRECT) + .location( + URI.create(String.format("/api/v3/targets/%d/recordingOptions", target.id))) + .build(); + } + + @PATCH + @Blocking + @Path("/api/v3/targets/{id}/recordingOptions") + @RolesAllowed("read") + public Map patchRecordingOptions( + @RestPath long id, + @RestForm String toDisk, + @RestForm String maxAge, + @RestForm String maxSize) + throws Exception { + final String unsetKeyword = "unset"; + + Map form = new HashMap<>(); + Pattern bool = Pattern.compile("true|false|" + unsetKeyword); + if (toDisk != null) { + Matcher m = bool.matcher(toDisk); + if (!m.matches()) { + throw new BadRequestException("Invalid options"); + } + form.put("toDisk", toDisk); + } + if (maxAge != null) { + if (!unsetKeyword.equals(maxAge)) { + try { + Long.parseLong(maxAge); + form.put("maxAge", maxAge); + } catch (NumberFormatException e) { + throw new BadRequestException("Invalid options"); + } + } + } + if (maxSize != null) { + if (!unsetKeyword.equals(maxSize)) { + try { + Long.parseLong(maxSize); + form.put("maxSize", maxSize); + } catch (NumberFormatException e) { + throw new BadRequestException("Invalid options"); + } + } + } + form.entrySet() + .forEach( + e -> { + RecordingOptionsCustomizer.OptionKey optionKey = + RecordingOptionsCustomizer.OptionKey.fromOptionName(e.getKey()) + .get(); + if ("unset".equals(e.getValue())) { + recordingOptionsCustomizer.unset(optionKey); + } else { + recordingOptionsCustomizer.set(optionKey, e.getValue()); + } + }); + + Target target = Target.find("id", id).singleResult(); + return connectionManager.executeConnectedTask( + target, + connection -> { + RecordingOptionsBuilder builder = + recordingOptionsBuilderFactory.create(connection.getService()); + return getRecordingOptions(connection.getService(), builder); + }); + } + @GET @Blocking @Path("/api/v3/activedownload/{id}") diff --git a/src/main/java/io/cryostat/recordings/RecordingsModule.java b/src/main/java/io/cryostat/recordings/RecordingsModule.java index 120323a29..081e2042e 100644 --- a/src/main/java/io/cryostat/recordings/RecordingsModule.java +++ b/src/main/java/io/cryostat/recordings/RecordingsModule.java @@ -19,7 +19,7 @@ import io.cryostat.core.RecordingOptionsCustomizer; -import io.quarkus.arc.DefaultBean; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; import jakarta.inject.Singleton; import org.slf4j.Logger; @@ -29,21 +29,21 @@ public class RecordingsModule { @Produces - @DefaultBean + @ApplicationScoped public RecordingOptionsBuilderFactory provideRecordingOptionsBuilderFactory( RecordingOptionsCustomizer customizer) { return service -> customizer.apply(new RecordingOptionsBuilder(service)); } @Produces - @DefaultBean + @ApplicationScoped public EventOptionsBuilder.Factory provideEventOptionsBuilderFactory() { Logger log = LoggerFactory.getLogger(EventOptionsBuilder.class); return new EventOptionsBuilder.Factory(log::debug); } @Produces - @DefaultBean + @ApplicationScoped public RecordingOptionsCustomizer provideRecordingOptionsCustomizer() { Logger log = LoggerFactory.getLogger(RecordingOptionsCustomizer.class); return new RecordingOptionsCustomizer(log::debug); diff --git a/src/test/java/itest/RecordingWorkflowTest.java b/src/test/java/itest/RecordingWorkflowTest.java index b23f7f7c4..a693da95c 100644 --- a/src/test/java/itest/RecordingWorkflowTest.java +++ b/src/test/java/itest/RecordingWorkflowTest.java @@ -30,6 +30,7 @@ import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -129,7 +130,7 @@ public void testWorkflow() throws Exception { TEST_RECORDING_NAME), true, saveHeaders, - "SAVE", + Buffer.buffer("SAVE"), REQUEST_TIMEOUT_SECONDS) .bodyAsString(); archivedRecordingFilenames.add(archivedRecordingFilename); diff --git a/src/test/java/itest/TargetRecordingPatchIT.java b/src/test/java/itest/TargetRecordingPatchIT.java deleted file mode 100644 index 0292c66b1..000000000 --- a/src/test/java/itest/TargetRecordingPatchIT.java +++ /dev/null @@ -1,157 +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.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -import io.cryostat.util.HttpMimeType; - -import io.quarkus.test.junit.QuarkusIntegrationTest; -import io.vertx.core.MultiMap; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.http.HttpHeaders; -import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; -import itest.bases.StandardSelfTest; -import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -@QuarkusIntegrationTest -@Disabled("TODO") -public class TargetRecordingPatchIT extends StandardSelfTest { - static final String TEST_RECORDING_NAME = "someRecording"; - static final String RECORDING_REQ_URL = - String.format("/api/v1/targets/%s/recordings", getSelfReferenceConnectUrl()); - static final String ARCHIVED_REQ_URL = "/api/v1/recordings"; - static final String OPTIONS_REQ_URL = - String.format("/api/v1/targets/%s/recordingOptions", getSelfReferenceConnectUrl()); - - @Test - void testSaveEmptyRecordingDoesNotArchiveRecordingFile() throws Exception { - try { - - CompletableFuture optionsResponse = new CompletableFuture<>(); - MultiMap optionsForm = MultiMap.caseInsensitiveMultiMap(); - optionsForm.add("toDisk", "false"); - optionsForm.add("maxSize", "0"); - - webClient - .patch(OPTIONS_REQ_URL) - .sendForm( - optionsForm, - ar -> { - if (assertRequestStatus(ar, optionsResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(200)); - optionsResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - - optionsResponse.get(); - - // Create an empty recording - CompletableFuture postResponse = new CompletableFuture<>(); - MultiMap form = MultiMap.caseInsensitiveMultiMap(); - form.add("recordingName", TEST_RECORDING_NAME); - form.add("duration", "5"); - form.add("events", "template=ALL"); - - webClient - .post(RECORDING_REQ_URL) - .sendForm( - form, - ar -> { - if (assertRequestStatus(ar, postResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(201)); - postResponse.complete(null); - } - }); - - postResponse.get(); - - // Attempt to save the recording to archive - CompletableFuture saveResponse = new CompletableFuture<>(); - webClient - .patch(String.format("%s/%s", RECORDING_REQ_URL, TEST_RECORDING_NAME)) - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), HttpMimeType.PLAINTEXT.mime()) - .sendBuffer( - Buffer.buffer("SAVE"), - ar -> { - if (assertRequestStatus(ar, saveResponse)) { - saveResponse.complete(ar.result().bodyAsString()); - } - }); - - MatcherAssert.assertThat(saveResponse.get(), Matchers.equalTo(null)); - - // Assert that no recording was archived - CompletableFuture listRespFuture1 = new CompletableFuture<>(); - webClient - .get(ARCHIVED_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()); - - } finally { - - // Clean up recording - CompletableFuture deleteActiveRecResponse = new CompletableFuture<>(); - webClient - .delete(String.format("%s/%s", RECORDING_REQ_URL, TEST_RECORDING_NAME)) - .send( - ar -> { - if (assertRequestStatus(ar, deleteActiveRecResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(200)); - deleteActiveRecResponse.complete( - ar.result().bodyAsJsonObject()); - } - }); - - MatcherAssert.assertThat(deleteActiveRecResponse.get(), Matchers.equalTo(null)); - - // Reset default target recording options - CompletableFuture optionsResponse = new CompletableFuture<>(); - MultiMap optionsForm = MultiMap.caseInsensitiveMultiMap(); - optionsForm.add("toDisk", "unset"); - optionsForm.add("maxSize", "unset"); - - webClient - .patch(OPTIONS_REQ_URL) - .sendForm( - optionsForm, - ar -> { - if (assertRequestStatus(ar, optionsResponse)) { - MatcherAssert.assertThat( - ar.result().statusCode(), Matchers.equalTo(200)); - optionsResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - - optionsResponse.get(); - } - } -} diff --git a/src/test/java/itest/TargetRecordingPatchTest.java b/src/test/java/itest/TargetRecordingPatchTest.java new file mode 100644 index 000000000..130e43657 --- /dev/null +++ b/src/test/java/itest/TargetRecordingPatchTest.java @@ -0,0 +1,121 @@ +/* + * 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.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.cryostat.util.HttpStatusCodeIdentifier; + +import io.quarkus.test.junit.QuarkusTest; +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonArray; +import io.vertx.ext.web.client.HttpResponse; +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.Test; + +@QuarkusTest +public class TargetRecordingPatchTest extends StandardSelfTest { + + static final String TEST_RECORDING_NAME = "someRecording"; + + String recordingRequestUrl() { + return String.format("/api/v1/targets/%s/recordings", getSelfReferenceConnectUrlEncoded()); + } + + String archivesRequestUrl() { + return "/api/v1/recordings"; + } + + String optionsRequestUrl() { + return String.format( + "/api/v1/targets/%s/recordingOptions", getSelfReferenceConnectUrlEncoded()); + } + + @Test + void testSaveEmptyRecordingDoesNotArchiveRecordingFile() throws Exception { + try { + MultiMap optionsForm = MultiMap.caseInsensitiveMultiMap(); + optionsForm.add("toDisk", "false"); + optionsForm.add("maxSize", "0"); + HttpResponse optionsResponse = + webClient.extensions().patch(optionsRequestUrl(), true, null, optionsForm, 5); + MatcherAssert.assertThat(optionsResponse.statusCode(), Matchers.equalTo(200)); + + // Create an empty recording + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "5"); + form.add("events", "template=ALL"); + HttpResponse postResponse = + webClient.extensions().post(recordingRequestUrl(), true, form, 5); + MatcherAssert.assertThat(postResponse.statusCode(), Matchers.equalTo(201)); + + // Attempt to save the recording to archive + HttpResponse saveResponse = + webClient + .extensions() + .patch( + String.format( + "%s/%s", recordingRequestUrl(), TEST_RECORDING_NAME), + true, + null, + Buffer.buffer("SAVE"), + 5); + MatcherAssert.assertThat(saveResponse.statusCode(), Matchers.equalTo(204)); + MatcherAssert.assertThat(saveResponse.body(), Matchers.equalTo(null)); + + // Assert that no recording was archived + CompletableFuture listRespFuture1 = new CompletableFuture<>(); + webClient + .get(archivesRequestUrl()) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, listRespFuture1)) { + listRespFuture1.complete(ar.result().bodyAsJsonArray()); + } + }); + JsonArray listResp = listRespFuture1.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Assertions.assertTrue(listResp.isEmpty()); + + } finally { + // Clean up recording + HttpResponse deleteResponse = + webClient + .extensions() + .delete( + String.format( + "%s/%s", recordingRequestUrl(), TEST_RECORDING_NAME), + true, + 5); + if (!HttpStatusCodeIdentifier.isSuccessCode(deleteResponse.statusCode())) { + throw new ITestCleanupFailedException(); + } + + // Reset default target recording options + MultiMap optionsForm = MultiMap.caseInsensitiveMultiMap(); + optionsForm.add("toDisk", "unset"); + optionsForm.add("maxSize", "unset"); + webClient.extensions().patch(optionsRequestUrl(), true, null, optionsForm, 5); + } + } +} diff --git a/src/test/java/itest/util/Utils.java b/src/test/java/itest/util/Utils.java index 3793c9402..61ef57bd4 100644 --- a/src/test/java/itest/util/Utils.java +++ b/src/test/java/itest/util/Utils.java @@ -90,7 +90,11 @@ HttpResponse delete(String url, boolean authentication, int timeout) throws InterruptedException, ExecutionException, TimeoutException; HttpResponse patch( - String url, boolean authentication, MultiMap headers, String action, int timeout) + String url, boolean authentication, MultiMap headers, Buffer payload, int timeout) + throws InterruptedException, ExecutionException, TimeoutException; + + HttpResponse patch( + String url, boolean authentication, MultiMap headers, MultiMap payload, int timeout) throws InterruptedException, ExecutionException, TimeoutException; } @@ -169,7 +173,7 @@ public HttpResponse patch( String url, boolean authentication, MultiMap headers, - String action, + Buffer payload, int timeout) throws InterruptedException, ExecutionException, TimeoutException { CompletableFuture> future = new CompletableFuture<>(); @@ -178,19 +182,53 @@ public HttpResponse patch( if (authentication) { req.basicAuthentication("user", "pass"); } - req.putHeaders(headers) - .sendBuffer( - Buffer.buffer(action), - ar -> { - if (ar.succeeded()) { - future.complete(ar.result()); - } else { - future.completeExceptionally(ar.cause()); - } - }); + if (headers != null) { + req.putHeaders(headers); + } + req.sendBuffer( + payload, + ar -> { + if (ar.succeeded()) { + future.complete(ar.result()); + } else { + future.completeExceptionally(ar.cause()); + } + }); + if (future.get().statusCode() == 308) { + return patch( + future.get().getHeader("Location"), true, headers, payload, timeout); + } + return future.get(timeout, TimeUnit.SECONDS); + } + + public HttpResponse patch( + String url, + boolean authentication, + MultiMap headers, + MultiMap payload, + int timeout) + throws InterruptedException, ExecutionException, TimeoutException { + CompletableFuture> future = new CompletableFuture<>(); + RequestOptions options = new RequestOptions().setURI(url); + HttpRequest req = TestWebClient.this.request(HttpMethod.PATCH, options); + if (authentication) { + req.basicAuthentication("user", "pass"); + } + if (headers != null) { + req.putHeaders(headers); + } + req.sendForm( + payload, + ar -> { + if (ar.succeeded()) { + future.complete(ar.result()); + } else { + future.completeExceptionally(ar.cause()); + } + }); if (future.get().statusCode() == 308) { return patch( - future.get().getHeader("Location"), true, headers, action, timeout); + future.get().getHeader("Location"), true, headers, payload, timeout); } return future.get(timeout, TimeUnit.SECONDS); }