diff --git a/src/main/java/io/cryostat/MainModule.java b/src/main/java/io/cryostat/MainModule.java index 6a0559e5e6..4a848c3b08 100644 --- a/src/main/java/io/cryostat/MainModule.java +++ b/src/main/java/io/cryostat/MainModule.java @@ -55,6 +55,7 @@ import io.cryostat.net.NetworkModule; import io.cryostat.net.web.http.HttpMimeType; import io.cryostat.platform.PlatformModule; +import io.cryostat.recordings.RecordingsModule; import io.cryostat.rules.Rule; import io.cryostat.rules.RulesModule; import io.cryostat.sys.SystemModule; @@ -80,6 +81,7 @@ CommandsModule.class, TemplatesModule.class, RulesModule.class, + RecordingsModule.class, }) public abstract class MainModule { public static final String RECORDINGS_PATH = "RECORDINGS_PATH"; diff --git a/src/main/java/io/cryostat/net/ConnectionDescriptor.java b/src/main/java/io/cryostat/net/ConnectionDescriptor.java index 79deede29a..d6d2c335e0 100644 --- a/src/main/java/io/cryostat/net/ConnectionDescriptor.java +++ b/src/main/java/io/cryostat/net/ConnectionDescriptor.java @@ -40,6 +40,7 @@ import java.util.Optional; import io.cryostat.core.net.Credentials; +import io.cryostat.platform.ServiceRef; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; @@ -49,10 +50,18 @@ public class ConnectionDescriptor { private final String targetId; private final Optional credentials; + public ConnectionDescriptor(ServiceRef serviceRef) { + this(serviceRef.getServiceUri().toString()); + } + public ConnectionDescriptor(String targetId) { this(targetId, null); } + public ConnectionDescriptor(ServiceRef serviceRef, Credentials credentials) { + this(serviceRef.getServiceUri().toString(), credentials); + } + public ConnectionDescriptor(String targetId, Credentials credentials) { this.targetId = targetId; this.credentials = Optional.ofNullable(credentials); diff --git a/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java b/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java index d26a41b9d9..329bf5a913 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java +++ b/src/main/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandler.java @@ -39,7 +39,6 @@ import java.io.IOException; import java.net.URISyntaxException; -import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; @@ -48,26 +47,22 @@ import javax.inject.Inject; import javax.inject.Provider; -import org.openjdk.jmc.common.unit.IConstrainedMap; import org.openjdk.jmc.common.unit.QuantityConversionException; -import org.openjdk.jmc.flightrecorder.configuration.events.EventOptionID; import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; -import org.openjdk.jmc.rjmx.services.jfr.IEventTypeInfo; import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; -import io.cryostat.commands.internal.EventOptionsBuilder; import io.cryostat.commands.internal.RecordingOptionsBuilderFactory; import io.cryostat.core.net.JFRConnection; -import io.cryostat.core.templates.Template; import io.cryostat.core.templates.TemplateType; import io.cryostat.jmc.serialization.HyperlinkedSerializableRecordingDescriptor; -import io.cryostat.messaging.notifications.NotificationFactory; import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; import io.cryostat.net.TargetConnectionManager; import io.cryostat.net.web.WebServer; import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; import io.cryostat.net.web.http.HttpMimeType; import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingCreationHelper; import com.google.gson.Gson; import io.vertx.core.MultiMap; @@ -76,45 +71,31 @@ import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.impl.HttpStatusException; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; public class TargetRecordingsPostHandler extends AbstractAuthenticatedRequestHandler { - // TODO extract this somewhere more appropriate - public static final Template ALL_EVENTS_TEMPLATE = - new Template( - "ALL", - "Enable all available events in the target JVM, with default option values. This will be very expensive and is intended primarily for testing Cryostat's own capabilities.", - "Cryostat", - TemplateType.TARGET); - - private static final Pattern TEMPLATE_PATTERN = - Pattern.compile("^template=([\\w]+)(?:,type=([\\w]+))?$"); - static final String PATH = "targets/:targetId/recordings"; private final TargetConnectionManager targetConnectionManager; + private final RecordingCreationHelper recordingCreationHelper; private final RecordingOptionsBuilderFactory recordingOptionsBuilderFactory; - private final EventOptionsBuilder.Factory eventOptionsBuilderFactory; private final Provider webServerProvider; private final Gson gson; - private final NotificationFactory notificationFactory; - private static final String NOTIFICATION_CATEGORY = "RecordingCreated"; @Inject TargetRecordingsPostHandler( AuthManager auth, TargetConnectionManager targetConnectionManager, + RecordingCreationHelper recordingCreationHelper, RecordingOptionsBuilderFactory recordingOptionsBuilderFactory, - EventOptionsBuilder.Factory eventOptionsBuilderFactory, Provider webServerProvider, - Gson gson, - NotificationFactory notificationFactory) { + Gson gson) { super(auth); this.targetConnectionManager = targetConnectionManager; + this.recordingCreationHelper = recordingCreationHelper; this.recordingOptionsBuilderFactory = recordingOptionsBuilderFactory; - this.eventOptionsBuilderFactory = eventOptionsBuilderFactory; this.webServerProvider = webServerProvider; this.gson = gson; - this.notificationFactory = notificationFactory; } @Override @@ -150,18 +131,11 @@ public void handleAuthenticated(RoutingContext ctx) throws Exception { } try { - Optional descriptor = + ConnectionDescriptor connectionDescriptor = getConnectionDescriptorFromContext(ctx); + HyperlinkedSerializableRecordingDescriptor linkedDescriptor = targetConnectionManager.executeConnectedTask( - getConnectionDescriptorFromContext(ctx), + connectionDescriptor, connection -> { - if (getDescriptorByName(connection, recordingName).isPresent()) { - throw new HttpStatusException( - 400, - String.format( - "Recording with name \"%s\" already exists", - recordingName)); - } - RecordingOptionsBuilder builder = recordingOptionsBuilderFactory .create(connection.getService()) @@ -185,57 +159,34 @@ public void handleAuthenticated(RoutingContext ctx) throws Exception { if (attrs.contains("maxSize")) { builder = builder.maxSize(Long.parseLong(attrs.get("maxSize"))); } - IConstrainedMap recordingOptions = builder.build(); - connection - .getService() - .start( - recordingOptions, - enableEvents(connection, eventSpecifier)); - notificationFactory - .createBuilder() - .metaCategory(NOTIFICATION_CATEGORY) - .metaType(HttpMimeType.JSON) - .message( - Map.of( - "recording", - recordingName, - "target", - getConnectionDescriptorFromContext(ctx) - .getTargetId())) - .build() - .send(); - return getDescriptorByName(connection, recordingName) - .map( - d -> { - try { - WebServer webServer = - webServerProvider.get(); - return new HyperlinkedSerializableRecordingDescriptor( - d, - webServer.getDownloadURL( - connection, d.getName()), - webServer.getReportURL( - connection, d.getName())); - } catch (QuantityConversionException - | URISyntaxException - | IOException e) { - throw new HttpStatusException(500, e); - } - }); + Pair template = + RecordingCreationHelper.parseEventSpecifierToTemplate( + eventSpecifier); + IRecordingDescriptor descriptor = + recordingCreationHelper.startRecording( + connectionDescriptor, + builder.build(), + template.getLeft(), + template.getRight()); + try { + WebServer webServer = webServerProvider.get(); + return new HyperlinkedSerializableRecordingDescriptor( + descriptor, + webServer.getDownloadURL( + connection, descriptor.getName()), + webServer.getReportURL( + connection, descriptor.getName())); + } catch (QuantityConversionException + | URISyntaxException + | IOException e) { + throw new HttpStatusException(500, e); + } }); - descriptor.ifPresentOrElse( - linkedDescriptor -> { - ctx.response().setStatusCode(201); - ctx.response().putHeader(HttpHeaders.LOCATION, "/" + recordingName); - ctx.response() - .putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.JSON.mime()); - ctx.response().end(gson.toJson(linkedDescriptor)); - }, - () -> { - throw new HttpStatusException( - 500, "Unexpected failure to create recording"); - }); + ctx.response().setStatusCode(201); + ctx.response().putHeader(HttpHeaders.LOCATION, "/" + recordingName); + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.JSON.mime()); + ctx.response().end(gson.toJson(linkedDescriptor)); } catch (NumberFormatException nfe) { throw new HttpStatusException( 400, String.format("Invalid argument: %s", nfe.getMessage()), nfe); @@ -250,59 +201,4 @@ protected Optional getDescriptorByName( .filter(recording -> recording.getName().equals(recordingName)) .findFirst(); } - - protected IConstrainedMap enableEvents(JFRConnection connection, String events) - throws Exception { - Matcher m = TEMPLATE_PATTERN.matcher(events); - m.find(); - String templateName = m.group(1); - String typeName = m.group(2); - if (ALL_EVENTS_TEMPLATE.getName().equals(templateName)) { - return enableAllEvents(connection); - } - if (typeName != null) { - return connection - .getTemplateService() - .getEvents(templateName, TemplateType.valueOf(typeName)) - .orElseThrow( - () -> - new IllegalArgumentException( - String.format( - "No template \"%s\" found with type %s", - templateName, typeName))); - } - // if template type not specified, try to find a Custom template by that name. If none, - // fall back on finding a Target built-in template by the name. If not, throw an - // exception and bail out. - return connection - .getTemplateService() - .getEvents(templateName, TemplateType.CUSTOM) - .or( - () -> { - try { - return connection - .getTemplateService() - .getEvents(templateName, TemplateType.TARGET); - } catch (Exception e) { - return Optional.empty(); - } - }) - .orElseThrow( - () -> - new IllegalArgumentException( - String.format( - "Invalid/unknown event template %s", - templateName))); - } - - protected IConstrainedMap enableAllEvents(JFRConnection connection) - throws Exception { - EventOptionsBuilder builder = eventOptionsBuilderFactory.create(connection); - - for (IEventTypeInfo eventTypeInfo : connection.getService().getAvailableEventTypes()) { - builder.addEvent(eventTypeInfo.getEventTypeID().getFullKey(), "enabled", "true"); - } - - return builder.build(); - } } diff --git a/src/main/java/io/cryostat/recordings/RecordingCreationHelper.java b/src/main/java/io/cryostat/recordings/RecordingCreationHelper.java new file mode 100644 index 0000000000..4a706673dd --- /dev/null +++ b/src/main/java/io/cryostat/recordings/RecordingCreationHelper.java @@ -0,0 +1,194 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.recordings; + +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.openjdk.jmc.common.unit.IConstrainedMap; +import org.openjdk.jmc.flightrecorder.configuration.events.EventOptionID; +import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; +import org.openjdk.jmc.rjmx.services.jfr.IEventTypeInfo; +import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; + +import io.cryostat.commands.internal.EventOptionsBuilder; +import io.cryostat.core.net.JFRConnection; +import io.cryostat.core.templates.TemplateType; +import io.cryostat.messaging.notifications.NotificationFactory; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.web.http.HttpMimeType; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; + +public class RecordingCreationHelper { + + private static final String NOTIFICATION_CATEGORY = "RecordingCreated"; + + private static final Pattern TEMPLATE_PATTERN = + Pattern.compile("^template=([\\w]+)(?:,type=([\\w]+))?$"); + + private final TargetConnectionManager targetConnectionManager; + private final EventOptionsBuilder.Factory eventOptionsBuilderFactory; + private final NotificationFactory notificationFactory; + + RecordingCreationHelper( + TargetConnectionManager targetConnectionManager, + EventOptionsBuilder.Factory eventOptionsBuilderFactory, + NotificationFactory notificationFactory) { + this.targetConnectionManager = targetConnectionManager; + this.eventOptionsBuilderFactory = eventOptionsBuilderFactory; + this.notificationFactory = notificationFactory; + } + + public IRecordingDescriptor startRecording( + ConnectionDescriptor connectionDescriptor, + IConstrainedMap recordingOptions, + String templateName, + TemplateType templateType) + throws Exception { + String recordingName = (String) recordingOptions.get(RecordingOptionsBuilder.KEY_NAME); + return targetConnectionManager.executeConnectedTask( + connectionDescriptor, + connection -> { + if (getDescriptorByName(connection, recordingName).isPresent()) { + throw new IllegalArgumentException( + String.format( + "Recording with name \"%s\" already exists", + recordingName)); + } + IRecordingDescriptor desc = + connection + .getService() + .start( + recordingOptions, + enableEvents(connection, templateName, templateType)); + notificationFactory + .createBuilder() + .metaCategory(NOTIFICATION_CATEGORY) + .metaType(HttpMimeType.JSON) + .message( + Map.of( + "recording", + recordingName, + "target", + connectionDescriptor.getTargetId())) + .build() + .send(); + return desc; + }); + } + + public static Pair parseEventSpecifierToTemplate(String eventSpecifier) + throws IllegalArgumentException { + if (TEMPLATE_PATTERN.matcher(eventSpecifier).matches()) { + Matcher m = TEMPLATE_PATTERN.matcher(eventSpecifier); + m.find(); + String templateName = m.group(1); + String typeName = m.group(2); + TemplateType templateType = null; + if (StringUtils.isNotBlank(typeName)) { + templateType = TemplateType.valueOf(typeName.toUpperCase()); + } + return Pair.of(templateName, templateType); + } + throw new IllegalArgumentException(eventSpecifier); + } + + private Optional getDescriptorByName( + JFRConnection connection, String recordingName) throws Exception { + return connection.getService().getAvailableRecordings().stream() + .filter(recording -> recording.getName().equals(recordingName)) + .findFirst(); + } + + private IConstrainedMap enableEvents( + JFRConnection connection, String templateName, TemplateType templateType) + throws Exception { + if (templateName.equals("ALL")) { + return enableAllEvents(connection); + } + if (templateType != null) { + return connection + .getTemplateService() + .getEvents(templateName, templateType) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "No template \"%s\" found with type %s", + templateName, templateType))); + } + // if template type not specified, try to find a Custom template by that name. If none, + // fall back on finding a Target built-in template by the name. If not, throw an + // exception and bail out. + return connection + .getTemplateService() + .getEvents(templateName, TemplateType.CUSTOM) + .or( + () -> { + try { + return connection + .getTemplateService() + .getEvents(templateName, TemplateType.TARGET); + } catch (Exception e) { + return Optional.empty(); + } + }) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Invalid/unknown event template %s", + templateName))); + } + + private IConstrainedMap enableAllEvents(JFRConnection connection) + throws Exception { + EventOptionsBuilder builder = eventOptionsBuilderFactory.create(connection); + + for (IEventTypeInfo eventTypeInfo : connection.getService().getAvailableEventTypes()) { + builder.addEvent(eventTypeInfo.getEventTypeID().getFullKey(), "enabled", "true"); + } + + return builder.build(); + } +} diff --git a/src/main/java/io/cryostat/recordings/RecordingsModule.java b/src/main/java/io/cryostat/recordings/RecordingsModule.java new file mode 100644 index 0000000000..684622f495 --- /dev/null +++ b/src/main/java/io/cryostat/recordings/RecordingsModule.java @@ -0,0 +1,61 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.recordings; + +import javax.inject.Singleton; + +import io.cryostat.commands.internal.EventOptionsBuilder; +import io.cryostat.messaging.notifications.NotificationFactory; +import io.cryostat.net.TargetConnectionManager; + +import dagger.Module; +import dagger.Provides; + +@Module +public abstract class RecordingsModule { + + @Provides + @Singleton + static RecordingCreationHelper provideRecordingCreationHelper( + TargetConnectionManager targetConnectionManager, + EventOptionsBuilder.Factory eventOptionsBuilderFactory, + NotificationFactory notificationFactory) { + return new RecordingCreationHelper( + targetConnectionManager, eventOptionsBuilderFactory, notificationFactory); + } +} diff --git a/src/main/java/io/cryostat/rules/RuleProcessor.java b/src/main/java/io/cryostat/rules/RuleProcessor.java index dc0816e5e0..9884debf3a 100644 --- a/src/main/java/io/cryostat/rules/RuleProcessor.java +++ b/src/main/java/io/cryostat/rules/RuleProcessor.java @@ -37,38 +37,33 @@ */ package io.cryostat.rules; -import java.net.URI; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; -import java.util.function.Function; +import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; + +import io.cryostat.commands.internal.RecordingOptionsBuilderFactory; import io.cryostat.configuration.CredentialsManager; import io.cryostat.core.log.Logger; import io.cryostat.core.net.Credentials; -import io.cryostat.core.net.discovery.JvmDiscoveryClient.EventKind; +import io.cryostat.core.templates.TemplateType; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; import io.cryostat.platform.PlatformClient; import io.cryostat.platform.ServiceRef; import io.cryostat.platform.TargetDiscoveryEvent; +import io.cryostat.recordings.RecordingCreationHelper; import io.cryostat.rules.RuleRegistry.RuleEvent; -import io.cryostat.util.HttpStatusCodeIdentifier; import io.cryostat.util.events.Event; import io.cryostat.util.events.EventListener; -import io.vertx.core.MultiMap; -import io.vertx.core.buffer.Buffer; -import io.vertx.ext.web.client.HttpResponse; -import io.vertx.ext.web.client.WebClient; -import io.vertx.ext.web.multipart.MultipartForm; import org.apache.commons.lang3.tuple.Pair; -import org.apache.http.client.utils.URLEncodedUtils; public class RuleProcessor implements Consumer, EventListener { @@ -77,9 +72,10 @@ public class RuleProcessor private final RuleRegistry registry; private final ScheduledExecutorService scheduler; private final CredentialsManager credentialsManager; - private final WebClient webClient; + private final RecordingOptionsBuilderFactory recordingOptionsBuilderFactory; + private final TargetConnectionManager targetConnectionManager; + private final RecordingCreationHelper recordingCreationHelper; private final PeriodicArchiverFactory periodicArchiverFactory; - private final Function headersFactory; private final Logger logger; private final Map, Future> tasks; @@ -89,17 +85,19 @@ public class RuleProcessor RuleRegistry registry, ScheduledExecutorService scheduler, CredentialsManager credentialsManager, - WebClient webClient, + RecordingOptionsBuilderFactory recordingOptionsBuilderFactory, + TargetConnectionManager targetConnectionManager, + RecordingCreationHelper recordingCreationHelper, PeriodicArchiverFactory periodicArchiverFactory, - Function headersFactory, Logger logger) { this.platformClient = platformClient; this.registry = registry; this.scheduler = scheduler; this.credentialsManager = credentialsManager; - this.webClient = webClient; + this.recordingOptionsBuilderFactory = recordingOptionsBuilderFactory; + this.targetConnectionManager = targetConnectionManager; + this.recordingCreationHelper = recordingCreationHelper; this.periodicArchiverFactory = periodicArchiverFactory; - this.headersFactory = headersFactory; this.logger = logger; this.tasks = new HashMap<>(); @@ -120,91 +118,82 @@ public synchronized void disable() { public synchronized void onEvent(Event event) { switch (event.getEventType()) { case ADDED: - // FIXME the processor should also be able to apply new rules to targets that have - // already appeared + platformClient.listDiscoverableServices().stream() + .filter(serviceRef -> registry.applies(event.getPayload(), serviceRef)) + .forEach(serviceRef -> activate(event.getPayload(), serviceRef)); break; case REMOVED: - Iterator, Future>> it = - tasks.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry, Future> entry = it.next(); - if (!Objects.equals(entry.getKey().getRight(), event.getPayload())) { - continue; - } - Future task = entry.getValue(); - if (task != null) { - task.cancel(true); - } - it.remove(); - } + deactivate(event.getPayload(), null); break; default: - throw new IllegalArgumentException(event.getEventType().toString()); + throw new UnsupportedOperationException(event.getEventType().toString()); } } @Override public synchronized void accept(TargetDiscoveryEvent tde) { - if (EventKind.LOST.equals(tde.getEventKind())) { - registry.getRules(tde.getServiceRef()) - .forEach( - rule -> { - Pair key = Pair.of(tde.getServiceRef(), rule); - Future task = tasks.remove(key); - if (task != null) { - task.cancel(true); - } - }); - return; + switch (tde.getEventKind()) { + case FOUND: + registry.getRules(tde.getServiceRef()) + .forEach(rule -> activate(rule, tde.getServiceRef())); + break; + case LOST: + deactivate(null, tde.getServiceRef()); + break; + default: + throw new UnsupportedOperationException(tde.getEventKind().toString()); } - if (!EventKind.FOUND.equals(tde.getEventKind())) { - throw new UnsupportedOperationException(tde.getEventKind().toString()); + } + + private void activate(Rule rule, ServiceRef serviceRef) { + this.logger.trace( + "Activating rule {} for target {}", rule.getName(), serviceRef.getServiceUri()); + + Credentials credentials = + credentialsManager.getCredentials(serviceRef.getServiceUri().toString()); + try { + startRuleRecording(new ConnectionDescriptor(serviceRef, credentials), rule); + } catch (Exception e) { + logger.error(e); } - registry.getRules(tde.getServiceRef()) - .forEach( - rule -> { - this.logger.trace( - "Activating rule {} for target {}", - rule.getName(), - tde.getServiceRef().getServiceUri()); - Credentials credentials = - credentialsManager.getCredentials( - tde.getServiceRef().getServiceUri().toString()); - try { - Future success = - startRuleRecording( - tde.getServiceRef().getServiceUri(), - rule.getRecordingName(), - rule.getEventSpecifier(), - rule.getMaxSizeBytes(), - rule.getMaxAgeSeconds(), - credentials); - if (!success.get()) { - logger.trace("Rule activation failed"); - return; - } - } catch (InterruptedException | ExecutionException e) { - logger.error(e); - } + logger.trace("Rule activation successful"); + if (rule.getPreservedArchives() <= 0 || rule.getArchivalPeriodSeconds() <= 0) { + return; + } + tasks.put( + Pair.of(serviceRef, rule), + scheduler.scheduleAtFixedRate( + periodicArchiverFactory.create( + serviceRef, credentialsManager, rule, this::archivalFailureHandler), + rule.getArchivalPeriodSeconds(), + rule.getArchivalPeriodSeconds(), + TimeUnit.SECONDS)); + } - logger.trace("Rule activation successful"); - if (rule.getPreservedArchives() <= 0 - || rule.getArchivalPeriodSeconds() <= 0) { - return; - } - tasks.put( - Pair.of(tde.getServiceRef(), rule), - scheduler.scheduleAtFixedRate( - periodicArchiverFactory.create( - tde.getServiceRef(), - credentialsManager, - rule, - this::archivalFailureHandler), - rule.getArchivalPeriodSeconds(), - rule.getArchivalPeriodSeconds(), - TimeUnit.SECONDS)); - }); + private void deactivate(Rule rule, ServiceRef serviceRef) { + if (rule == null && serviceRef == null) { + throw new IllegalArgumentException("Both parameters cannot be null"); + } + if (rule != null) { + logger.trace("Deactivating rule {}", rule.getName()); + } + if (serviceRef != null) { + logger.trace("Deactivating rules for {}", serviceRef.getServiceUri()); + } + Iterator, Future>> it = tasks.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry, Future> entry = it.next(); + boolean sameRule = Objects.equals(entry.getKey().getRight(), rule); + boolean sameTarget = Objects.equals(entry.getKey().getLeft(), serviceRef); + if (sameRule || sameTarget) { + Future task = entry.getValue(); + if (task != null) { + task.cancel(true); + } + it.remove(); + } + } } private Void archivalFailureHandler(Pair id) { @@ -215,60 +204,32 @@ private Void archivalFailureHandler(Pair id) { return null; } - private Future startRuleRecording( - URI serviceUri, - String recordingName, - String eventSpecifier, - int maxSizeBytes, - int maxAgeSeconds, - Credentials credentials) { - // FIXME using an HTTP request to localhost here works well enough, but is needlessly - // complex. The API handler targeted here should be refactored to extract the logic that - // creates the recording from the logic that simply figures out the recording parameters - // from the POST form, path param, and headers. Then the handler should consume the API - // exposed by this refactored chunk, and this refactored chunk can also be consumed here - // rather than firing HTTP requests to ourselves - MultipartForm form = MultipartForm.create(); - form.attribute("recordingName", recordingName); - form.attribute("events", eventSpecifier); - if (maxAgeSeconds > 0) { - form.attribute("maxAge", String.valueOf(maxAgeSeconds)); - } - if (maxSizeBytes > 0) { - form.attribute("maxSize", String.valueOf(maxSizeBytes)); - } - String path = - URI.create( - String.format( - "/api/v1/targets/%s/recordings", - URLEncodedUtils.formatSegments(serviceUri.toString()))) - .normalize() - .toString(); - - this.logger.trace("POST {}", path); - - CompletableFuture result = new CompletableFuture<>(); - this.webClient - .post(path) - .putHeaders(headersFactory.apply(credentials)) - .sendMultipartForm( - form, - ar -> { - if (ar.failed()) { - this.logger.error( - new RuntimeException( - "Activation of automatic rule failed", ar.cause())); - result.completeExceptionally(ar.cause()); - return; - } - HttpResponse resp = ar.result(); - if (!HttpStatusCodeIdentifier.isSuccessCode(resp.statusCode())) { - this.logger.error(resp.bodyAsString()); - result.complete(false); - return; - } - result.complete(true); - }); - return result; + private void startRuleRecording(ConnectionDescriptor connectionDescriptor, Rule rule) + throws Exception { + + targetConnectionManager.executeConnectedTask( + connectionDescriptor, + connection -> { + RecordingOptionsBuilder builder = + recordingOptionsBuilderFactory + .create(connection.getService()) + .name(rule.getRecordingName()) + .toDisk(true); + if (rule.getMaxAgeSeconds() > 0) { + builder = builder.maxAge(rule.getMaxAgeSeconds()); + } + if (rule.getMaxSizeBytes() > 0) { + builder = builder.maxSize(rule.getMaxSizeBytes()); + } + Pair template = + RecordingCreationHelper.parseEventSpecifierToTemplate( + rule.getEventSpecifier()); + recordingCreationHelper.startRecording( + connectionDescriptor, + builder.build(), + template.getLeft(), + template.getRight()); + return null; + }); } } diff --git a/src/main/java/io/cryostat/rules/RuleRegistry.java b/src/main/java/io/cryostat/rules/RuleRegistry.java index 204b1c6560..27765231b2 100644 --- a/src/main/java/io/cryostat/rules/RuleRegistry.java +++ b/src/main/java/io/cryostat/rules/RuleRegistry.java @@ -116,13 +116,15 @@ public Optional getRule(String name) { return this.rules.stream().filter(r -> Objects.equals(r.getName(), name)).findFirst(); } + public boolean applies(Rule rule, ServiceRef serviceRef) { + return Objects.equals(rule.getTargetAlias(), serviceRef.getAlias().get()); + } + public Set getRules(ServiceRef serviceRef) { if (!serviceRef.getAlias().isPresent()) { return Set.of(); } - return rules.stream() - .filter(r -> r.getTargetAlias().equals(serviceRef.getAlias().get())) - .collect(Collectors.toSet()); + return rules.stream().filter(r -> applies(r, serviceRef)).collect(Collectors.toSet()); } public Set getRules() { diff --git a/src/main/java/io/cryostat/rules/RulesModule.java b/src/main/java/io/cryostat/rules/RulesModule.java index 79835def12..05d1a75646 100644 --- a/src/main/java/io/cryostat/rules/RulesModule.java +++ b/src/main/java/io/cryostat/rules/RulesModule.java @@ -47,6 +47,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import io.cryostat.commands.internal.RecordingOptionsBuilderFactory; import io.cryostat.configuration.ConfigurationModule; import io.cryostat.configuration.CredentialsManager; import io.cryostat.core.log.Logger; @@ -54,8 +55,10 @@ import io.cryostat.core.sys.FileSystem; import io.cryostat.net.HttpServer; import io.cryostat.net.NetworkConfiguration; +import io.cryostat.net.TargetConnectionManager; import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; import io.cryostat.platform.PlatformClient; +import io.cryostat.recordings.RecordingCreationHelper; import com.google.gson.Gson; import dagger.Module; @@ -96,18 +99,20 @@ static RuleProcessor provideRuleProcessor( PlatformClient platformClient, RuleRegistry registry, CredentialsManager credentialsManager, - @Named(RULES_WEB_CLIENT) WebClient webClient, + RecordingOptionsBuilderFactory recordingOptionsBuilderFactory, + TargetConnectionManager targetConnectionManager, + RecordingCreationHelper recordingCreationHelper, PeriodicArchiverFactory periodicArchiverFactory, - @Named(RULES_HEADERS_FACTORY) Function headersFactory, Logger logger) { return new RuleProcessor( platformClient, registry, Executors.newScheduledThreadPool(1), credentialsManager, - webClient, + recordingOptionsBuilderFactory, + targetConnectionManager, + recordingCreationHelper, periodicArchiverFactory, - headersFactory, logger); } diff --git a/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java index a5884964e5..4c00266227 100644 --- a/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java +++ b/src/test/java/io/cryostat/net/web/http/api/v1/TargetRecordingsPostHandlerTest.java @@ -37,13 +37,7 @@ */ package io.cryostat.net.web.http.api.v1; -import static org.mockito.Mockito.lenient; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -51,24 +45,21 @@ import org.openjdk.jmc.common.unit.IConstrainedMap; import org.openjdk.jmc.common.unit.IQuantity; import org.openjdk.jmc.common.unit.QuantityConversionException; -import org.openjdk.jmc.flightrecorder.configuration.events.EventOptionID; import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; import org.openjdk.jmc.rjmx.services.jfr.IFlightRecorderService; import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; import io.cryostat.MainModule; -import io.cryostat.commands.internal.EventOptionsBuilder; import io.cryostat.commands.internal.RecordingOptionsBuilderFactory; import io.cryostat.core.log.Logger; import io.cryostat.core.net.JFRConnection; import io.cryostat.core.templates.TemplateService; import io.cryostat.core.templates.TemplateType; -import io.cryostat.messaging.notifications.Notification; -import io.cryostat.messaging.notifications.NotificationFactory; import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; import io.cryostat.net.TargetConnectionManager; import io.cryostat.net.web.WebServer; -import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.recordings.RecordingCreationHelper; import com.google.gson.Gson; import io.vertx.core.MultiMap; @@ -97,13 +88,10 @@ class TargetRecordingsPostHandlerTest { TargetRecordingsPostHandler handler; @Mock AuthManager auth; @Mock TargetConnectionManager targetConnectionManager; + @Mock RecordingCreationHelper recordingCreationHelper; @Mock RecordingOptionsBuilderFactory recordingOptionsBuilderFactory; - @Mock EventOptionsBuilder.Factory eventOptionsBuilderFactory; @Mock WebServer webServer; @Mock Logger logger; - @Mock NotificationFactory notificationFactory; - @Mock Notification notification; - @Mock Notification.Builder notificationBuilder; Gson gson = MainModule.provideGson(logger); @Mock JFRConnection connection; @@ -119,23 +107,10 @@ void setup() { new TargetRecordingsPostHandler( auth, targetConnectionManager, + recordingCreationHelper, recordingOptionsBuilderFactory, - eventOptionsBuilderFactory, () -> webServer, - gson, - notificationFactory); - lenient().when(notificationFactory.createBuilder()).thenReturn(notificationBuilder); - lenient() - .when(notificationBuilder.metaCategory(Mockito.any())) - .thenReturn(notificationBuilder); - lenient() - .when(notificationBuilder.metaType(Mockito.any(Notification.MetaType.class))) - .thenReturn(notificationBuilder); - lenient() - .when(notificationBuilder.metaType(Mockito.any(HttpMimeType.class))) - .thenReturn(notificationBuilder); - lenient().when(notificationBuilder.message(Mockito.any())).thenReturn(notificationBuilder); - lenient().when(notificationBuilder.build()).thenReturn(notification); + gson); } @Test @@ -176,7 +151,6 @@ void shouldStartRecording() throws Exception { Mockito.when(recordingOptionsBuilder.maxSize(Mockito.anyLong())) .thenReturn(recordingOptionsBuilder); Mockito.when(recordingOptionsBuilder.build()).thenReturn(recordingOptions); - IConstrainedMap events = Mockito.mock(IConstrainedMap.class); Mockito.when( webServer.getDownloadURL( @@ -185,72 +159,77 @@ void shouldStartRecording() throws Exception { Mockito.when(webServer.getReportURL(Mockito.any(JFRConnection.class), Mockito.anyString())) .thenReturn("example-report-url"); - IRecordingDescriptor descriptor = createDescriptor("someRecording"); - Mockito.when(service.start(Mockito.any(), Mockito.any())).thenReturn(descriptor); - Mockito.when(service.getAvailableRecordings()) - .thenReturn(Collections.emptyList()) - .thenReturn(List.of(descriptor)); - Mockito.when(ctx.pathParam("targetId")).thenReturn("fooHost:9091"); MultiMap attrs = MultiMap.caseInsensitiveMultiMap(); Mockito.when(ctx.request()).thenReturn(req); Mockito.when(req.headers()).thenReturn(MultiMap.caseInsensitiveMultiMap()); Mockito.when(req.formAttributes()).thenReturn(attrs); attrs.add("recordingName", "someRecording"); - attrs.add("events", "template=Foo"); + attrs.add("events", "template=Foo,type=CUSTOM"); attrs.add("duration", "10"); attrs.add("toDisk", "true"); attrs.add("maxAge", "50"); attrs.add("maxSize", "64"); - Mockito.when(connection.getTemplateService()).thenReturn(templateService); - Mockito.when(templateService.getEvents("Foo", TemplateType.CUSTOM)) - .thenReturn(Optional.of(events)); Mockito.when(ctx.response()).thenReturn(resp); - handler.handle(ctx); + IRecordingDescriptor descriptor = createDescriptor("someRecording"); + Mockito.when( + recordingCreationHelper.startRecording( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(descriptor); - Mockito.verify(resp).setStatusCode(201); - Mockito.verify(resp).putHeader(HttpHeaders.LOCATION, "/someRecording"); - Mockito.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "application/json"); - Mockito.verify(resp) - .end( - "{\"downloadUrl\":\"example-download-url\",\"reportUrl\":\"example-report-url\",\"id\":1,\"name\":\"someRecording\",\"state\":\"STOPPED\",\"startTime\":0,\"duration\":0,\"continuous\":false,\"toDisk\":false,\"maxSize\":0,\"maxAge\":0}"); + handler.handle(ctx); - ArgumentCaptor> recordingOptionsCaptor = - ArgumentCaptor.forClass(IConstrainedMap.class); - ArgumentCaptor> eventsCaptor = - ArgumentCaptor.forClass(IConstrainedMap.class); Mockito.verify(recordingOptionsBuilder).name("someRecording"); Mockito.verify(recordingOptionsBuilder).duration(TimeUnit.SECONDS.toMillis(10)); Mockito.verify(recordingOptionsBuilder).toDisk(true); Mockito.verify(recordingOptionsBuilder).maxAge(50L); Mockito.verify(recordingOptionsBuilder).maxSize(64L); - Mockito.verify(service, Mockito.atLeastOnce()).getAvailableRecordings(); - Mockito.verify(service).start(recordingOptionsCaptor.capture(), eventsCaptor.capture()); - IConstrainedMap actualRecordingOptions = recordingOptionsCaptor.getValue(); - IConstrainedMap actualEvents = eventsCaptor.getValue(); + ArgumentCaptor connectionDescriptorCaptor = + ArgumentCaptor.forClass(ConnectionDescriptor.class); + + ArgumentCaptor> recordingOptionsCaptor = + ArgumentCaptor.forClass(IConstrainedMap.class); + + ArgumentCaptor templateNameCaptor = ArgumentCaptor.forClass(String.class); - MatcherAssert.assertThat(actualEvents, Matchers.sameInstance(events)); + ArgumentCaptor templateTypeCaptor = + ArgumentCaptor.forClass(TemplateType.class); + + Mockito.verify(recordingCreationHelper) + .startRecording( + connectionDescriptorCaptor.capture(), + recordingOptionsCaptor.capture(), + templateNameCaptor.capture(), + templateTypeCaptor.capture()); + + ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); + MatcherAssert.assertThat( + connectionDescriptor.getTargetId(), Matchers.equalTo("fooHost:9091")); + MatcherAssert.assertThat( + connectionDescriptor.getCredentials().isEmpty(), Matchers.is(true)); + + IConstrainedMap actualRecordingOptions = recordingOptionsCaptor.getValue(); MatcherAssert.assertThat(actualRecordingOptions, Matchers.sameInstance(recordingOptions)); - Mockito.verify(connection).getTemplateService(); - Mockito.verify(templateService).getEvents("Foo", TemplateType.CUSTOM); + MatcherAssert.assertThat(templateNameCaptor.getValue(), Matchers.equalTo("Foo")); - Mockito.verify(notificationFactory).createBuilder(); - Mockito.verify(notificationBuilder).metaCategory("RecordingCreated"); - Mockito.verify(notificationBuilder).metaType(HttpMimeType.JSON); - Mockito.verify(notificationBuilder) - .message(Map.of("recording", "someRecording", "target", "fooHost:9091")); - Mockito.verify(notificationBuilder).build(); - Mockito.verify(notification).send(); + MatcherAssert.assertThat( + templateTypeCaptor.getValue(), Matchers.equalTo(TemplateType.CUSTOM)); + + Mockito.verify(resp).setStatusCode(201); + Mockito.verify(resp).putHeader(HttpHeaders.LOCATION, "/someRecording"); + Mockito.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + Mockito.verify(resp) + .end( + "{\"downloadUrl\":\"example-download-url\",\"reportUrl\":\"example-report-url\",\"id\":1,\"name\":\"someRecording\",\"state\":\"STOPPED\",\"startTime\":0,\"duration\":0,\"continuous\":false,\"toDisk\":false,\"maxSize\":0,\"maxAge\":0}"); } @Test void shouldHandleNameCollision() throws Exception { Mockito.when(auth.validateHttpHeader(Mockito.any())) .thenReturn(CompletableFuture.completedFuture(true)); - IRecordingDescriptor existingRecording = createDescriptor("someRecording"); Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) .thenAnswer( @@ -258,8 +237,19 @@ void shouldHandleNameCollision() throws Exception { ((TargetConnectionManager.ConnectedTask) arg0.getArgument(1)) .execute(connection)); - Mockito.when(connection.getService()).thenReturn(service); - Mockito.when(service.getAvailableRecordings()).thenReturn(Arrays.asList(existingRecording)); + + IConstrainedMap recordingOptions = Mockito.mock(IConstrainedMap.class); + RecordingOptionsBuilder recordingOptionsBuilder = + Mockito.mock(RecordingOptionsBuilder.class); + Mockito.when(recordingOptionsBuilderFactory.create(Mockito.any())) + .thenReturn(recordingOptionsBuilder); + Mockito.when(recordingOptionsBuilder.name(Mockito.any())) + .thenReturn(recordingOptionsBuilder); + Mockito.when(recordingOptionsBuilder.build()).thenReturn(recordingOptions); + Mockito.when( + recordingCreationHelper.startRecording( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenThrow(IllegalArgumentException.class); Mockito.when(ctx.pathParam("targetId")).thenReturn("fooHost:9091"); MultiMap attrs = MultiMap.caseInsensitiveMultiMap(); @@ -272,8 +262,6 @@ void shouldHandleNameCollision() throws Exception { HttpStatusException ex = Assertions.assertThrows(HttpStatusException.class, () -> handler.handle(ctx)); MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(400)); - - Mockito.verify(service).getAvailableRecordings(); } @Test @@ -315,9 +303,6 @@ void shouldThrowInvalidOptionException(Map requestValues) throws arg0.getArgument(1)) .execute(connection)); Mockito.when(connection.getService()).thenReturn(service); - Mockito.when(service.getAvailableRecordings()) - .thenReturn(Collections.emptyList()) - .thenReturn(Arrays.asList(existingRecording)); RecordingOptionsBuilder recordingOptionsBuilder = Mockito.mock(RecordingOptionsBuilder.class); Mockito.when(recordingOptionsBuilderFactory.create(Mockito.any())) diff --git a/src/test/java/io/cryostat/rules/RuleProcessorTest.java b/src/test/java/io/cryostat/rules/RuleProcessorTest.java index 46b104bde8..6bf9007a3a 100644 --- a/src/test/java/io/cryostat/rules/RuleProcessorTest.java +++ b/src/test/java/io/cryostat/rules/RuleProcessorTest.java @@ -38,30 +38,30 @@ package io.cryostat.rules; import java.net.URI; -import java.util.HashSet; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Function; +import org.openjdk.jmc.common.unit.IConstrainedMap; +import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; +import org.openjdk.jmc.rjmx.services.jfr.IFlightRecorderService; + +import io.cryostat.commands.internal.RecordingOptionsBuilderFactory; import io.cryostat.configuration.CredentialsManager; import io.cryostat.core.log.Logger; import io.cryostat.core.net.Credentials; +import io.cryostat.core.net.JFRConnection; import io.cryostat.core.net.discovery.JvmDiscoveryClient.EventKind; +import io.cryostat.core.templates.TemplateType; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; import io.cryostat.platform.PlatformClient; import io.cryostat.platform.ServiceRef; import io.cryostat.platform.TargetDiscoveryEvent; +import io.cryostat.recordings.RecordingCreationHelper; -import io.vertx.core.AsyncResult; -import io.vertx.core.Handler; -import io.vertx.core.MultiMap; -import io.vertx.core.buffer.Buffer; -import io.vertx.ext.web.client.HttpRequest; -import io.vertx.ext.web.client.HttpResponse; -import io.vertx.ext.web.client.WebClient; -import io.vertx.ext.web.multipart.MultipartForm; -import io.vertx.ext.web.multipart.impl.FormDataPartImpl; import org.apache.commons.lang3.tuple.Pair; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; @@ -71,9 +71,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.stubbing.Answer; @ExtendWith(MockitoExtension.class) class RuleProcessorTest { @@ -83,11 +81,15 @@ class RuleProcessorTest { @Mock RuleRegistry registry; @Mock ScheduledExecutorService scheduler; @Mock CredentialsManager credentialsManager; - @Mock WebClient webClient; + @Mock RecordingOptionsBuilderFactory recordingOptionsBuilderFactory; + @Mock TargetConnectionManager targetConnectionManager; + @Mock RecordingCreationHelper recordingCreationHelper; @Mock PeriodicArchiverFactory periodicArchiverFactory; - @Mock MultiMap headers; @Mock Logger logger; + @Mock JFRConnection connection; + @Mock IFlightRecorderService service; + @BeforeEach void setup() { this.processor = @@ -96,9 +98,10 @@ void setup() { registry, scheduler, credentialsManager, - webClient, + recordingOptionsBuilderFactory, + targetConnectionManager, + recordingCreationHelper, periodicArchiverFactory, - c -> headers, logger); } @@ -122,25 +125,28 @@ void testDisableRemovesProcessorAsDiscoveryListener() { @Test void testSuccessfulRuleActivationWithCredentials() throws Exception { - HttpRequest request = Mockito.mock(HttpRequest.class); - HttpResponse response = Mockito.mock(HttpResponse.class); - Mockito.when(response.statusCode()).thenReturn(200); - - Mockito.when(webClient.post(Mockito.any())).thenReturn(request); - Mockito.when(request.putHeaders(Mockito.any())).thenReturn(request); - Mockito.doAnswer( - new Answer() { - @Override - public Void answer(InvocationOnMock invocation) throws Throwable { - AsyncResult res = Mockito.mock(AsyncResult.class); - Mockito.when(res.failed()).thenReturn(false); - Mockito.when(res.result()).thenReturn(response); - ((Handler) invocation.getArgument(1)).handle(res); - return null; - } - }) - .when(request) - .sendMultipartForm(Mockito.any(), Mockito.any()); + RecordingOptionsBuilder recordingOptionsBuilder = + Mockito.mock(RecordingOptionsBuilder.class); + Mockito.when(recordingOptionsBuilder.name(Mockito.any())) + .thenReturn(recordingOptionsBuilder); + Mockito.when(recordingOptionsBuilder.toDisk(Mockito.anyBoolean())) + .thenReturn(recordingOptionsBuilder); + Mockito.when(recordingOptionsBuilder.maxAge(Mockito.anyLong())) + .thenReturn(recordingOptionsBuilder); + Mockito.when(recordingOptionsBuilder.maxSize(Mockito.anyLong())) + .thenReturn(recordingOptionsBuilder); + Mockito.when(recordingOptionsBuilderFactory.create(Mockito.any())) + .thenReturn(recordingOptionsBuilder); + IConstrainedMap recordingOptions = Mockito.mock(IConstrainedMap.class); + Mockito.when(recordingOptionsBuilder.build()).thenReturn(recordingOptions); + + Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenAnswer( + arg0 -> + ((TargetConnectionManager.ConnectedTask) + arg0.getArgument(1)) + .execute(connection)); + Mockito.when(connection.getService()).thenReturn(service); String jmxUrl = "service:jmx:rmi://localhost:9091/jndi/rmi://fooHost:9091/jmxrmi"; ServiceRef serviceRef = new ServiceRef(new URI(jmxUrl), "com.example.App"); @@ -172,54 +178,47 @@ public Void answer(InvocationOnMock invocation) throws Throwable { processor.accept(tde); - ArgumentCaptor formCaptor = ArgumentCaptor.forClass(MultipartForm.class); - Mockito.verify(request).sendMultipartForm(formCaptor.capture(), Mockito.any()); - MultipartForm form = formCaptor.getValue(); - Set formAttributes = new HashSet<>(); - form.iterator() - .forEachRemaining( - part -> { - FormDataPartImpl impl = (FormDataPartImpl) part; - formAttributes.add(String.format("%s=%s", impl.name(), impl.value())); - }); + Mockito.verify(recordingOptionsBuilder).name("auto_Test_Rule"); + Mockito.verify(recordingOptionsBuilder).maxAge(30); + Mockito.verify(recordingOptionsBuilder).maxSize(1234); + + ArgumentCaptor connectionDescriptorCaptor = + ArgumentCaptor.forClass(ConnectionDescriptor.class); + + ArgumentCaptor> recordingOptionsCaptor = + ArgumentCaptor.forClass(IConstrainedMap.class); + + ArgumentCaptor templateNameCaptor = ArgumentCaptor.forClass(String.class); + + ArgumentCaptor templateTypeCaptor = + ArgumentCaptor.forClass(TemplateType.class); + + Mockito.verify(recordingCreationHelper) + .startRecording( + connectionDescriptorCaptor.capture(), + recordingOptionsCaptor.capture(), + templateNameCaptor.capture(), + templateTypeCaptor.capture()); + + ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); + MatcherAssert.assertThat( + connectionDescriptor.getTargetId(), + Matchers.equalTo(serviceRef.getServiceUri().toString())); MatcherAssert.assertThat( - formAttributes, - Matchers.containsInAnyOrder( - "recordingName=auto_Test_Rule", - "events=template=Continuous", - "maxAge=30", - "maxSize=1234")); - - ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class); - Mockito.verify(request).putHeaders(headersCaptor.capture()); - MultiMap capturedHeaders = headersCaptor.getValue(); - MatcherAssert.assertThat(capturedHeaders, Matchers.sameInstance(headers)); + connectionDescriptor.getCredentials().get(), Matchers.equalTo(credentials)); + + IConstrainedMap actualRecordingOptions = recordingOptionsCaptor.getValue(); + MatcherAssert.assertThat(actualRecordingOptions, Matchers.sameInstance(recordingOptions)); + + MatcherAssert.assertThat(templateNameCaptor.getValue(), Matchers.equalTo("Continuous")); + + MatcherAssert.assertThat(templateTypeCaptor.getValue(), Matchers.nullValue()); Mockito.verify(scheduler).scheduleAtFixedRate(periodicArchiver, 67, 67, TimeUnit.SECONDS); } @Test void testTaskCancellationOnFailure() throws Exception { - HttpRequest request = Mockito.mock(HttpRequest.class); - HttpResponse response = Mockito.mock(HttpResponse.class); - Mockito.when(response.statusCode()).thenReturn(200); - - Mockito.when(webClient.post(Mockito.any())).thenReturn(request); - Mockito.when(request.putHeaders(Mockito.any())).thenReturn(request); - Mockito.doAnswer( - new Answer() { - @Override - public Void answer(InvocationOnMock invocation) throws Throwable { - AsyncResult res = Mockito.mock(AsyncResult.class); - Mockito.when(res.failed()).thenReturn(false); - Mockito.when(res.result()).thenReturn(response); - ((Handler) invocation.getArgument(1)).handle(res); - return null; - } - }) - .when(request) - .sendMultipartForm(Mockito.any(), Mockito.any()); - String jmxUrl = "service:jmx:rmi://localhost:9091/jndi/rmi://fooHost:9091/jmxrmi"; ServiceRef serviceRef = new ServiceRef(new URI(jmxUrl), "com.example.App");