/*
* Copyright (c) 2013-2017 Cinchapi Inc.
*
* 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 com.cinchapi.concourse.server.plugin;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.ByteBuffer;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.zip.ZipException;
import org.apache.commons.lang.StringUtils;
import org.reflections.Reflections;
import org.reflections.util.ClasspathHelper;
import org.reflections.util.ConfigurationBuilder;
import com.cinchapi.common.reflect.Reflection;
import com.cinchapi.concourse.server.ConcourseServer;
import com.cinchapi.concourse.server.io.FileSystem;
import com.cinchapi.concourse.server.io.process.JavaApp;
import com.cinchapi.concourse.server.plugin.data.WriteEvent;
import com.cinchapi.concourse.server.plugin.hook.AfterInstallHook;
import com.cinchapi.concourse.server.plugin.io.InterProcessCommunication;
import com.cinchapi.concourse.server.plugin.io.MessageQueue;
import com.cinchapi.concourse.server.plugin.io.PluginSerializer;
import com.cinchapi.concourse.server.plugin.util.Versions;
import com.cinchapi.concourse.thrift.AccessToken;
import com.cinchapi.concourse.thrift.ComplexTObject;
import com.cinchapi.concourse.thrift.TransactionToken;
import com.cinchapi.concourse.time.Time;
import com.cinchapi.concourse.util.ConcurrentMaps;
import com.cinchapi.concourse.util.Logger;
import com.cinchapi.concourse.util.MorePaths;
import com.cinchapi.concourse.util.Queues;
import com.cinchapi.concourse.util.Resources;
import com.cinchapi.concourse.util.Strings;
import com.cinchapi.concourse.util.ZipFiles;
import com.github.zafarkhaja.semver.Version;
import com.google.common.base.Throwables;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import com.google.common.io.BaseEncoding;
import com.google.common.io.CharStreams;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import static com.cinchapi.concourse.server.GlobalState.BINARY_QUEUE;
/**
* <p>
* A {@link PluginManager} is responsible for handling all things (i.e.
* starting, stopping, etc) related to plugins.
* </p>
* <h1>Working with Plugins</h1>
* <p>
* Any class that extends {@link Plugin} is considered a plugin. Plugins run in
* a separate JVM, but are completely and transparently managed by this
* {@link PluginManager class} via the utilities provided in the {@link JavaApp}
* class.
* </p>
* <h2>Bundles</h2>
* <p>
* One or more plugins are packaged together in {@code bundles}. A bundle is
* essentially a zip archive that contains the Plugin class(es) and all
* necessary dependencies. All the plugins in a bundle are
* {@link #installBundle(String) installed} and {@link #uninstallBundle(String)
* uninstalled} together.
* </p>
* <p>
* Usually, a bundle will only contain a single plugin; however, it is possible
* to bundle multiple plugins if they all have same dependencies. When a bundle
* contains multiple plugins, each of the plugins is still launch and managed in
* a separate JVM
* </p>
* <h2>Plugin Lifecycle</h2>
* <p>
* When the PluginManager starts, it goes through all the bundles and
* {@link #activate(String) activates} each Plugin. Part of the activation
* routine is {@link #launch(String, Path, Class, List) launching} the plugin in
* the external JVM.
* </p>
* <h2>Plugin Communication</h2>
* <p>
* Plugins communicate with Concourse Server via
* {@link InterProcessCommunication} streams setup by the {@link PluginManager}.
* Each plugin has two streams:
* <ol>
* <li>A {@code fromServer} stream that serves as a communication channel for
* messages that come from Concourse Server and are read by the Plugin.
* Internally, the plugin sets up an event loop that reads messages on the
* {@code fromServer} stream and dispatches {@link Instruction#REQUEST requests}
* to asynchronous worker threads while {@link Instruction#RESPONSE responses}
* are placed on a message queue that is read by the local worker threads.</li>
* <li>A {@code fromPlugin} stream that serves as a communication channel for
* messages that come from Plugin and are read by the {@link PluginManager} on
* behalf of Concourse Server. Internally, the PluginManager sets up an
* {@link #startEventLoop(String) event loop} that reads messages on the
* {@code fromPlugn} stream and dispatches {@link Instruction#REQUEST requests}
* to asynchronous worker threads while {@link Instruction#RESPONSE responses}
* are placed on a {@link RegistryData#FROM_PLUGIN_RESPONSES message queue} that
* is read by the local worker threads.</li>
* </ol>
* </p>
* <p>
* Plugins are only allowed to communicate with Concourse Server (e.g. they
* cannot communicate directly with other plugins).
* </p>
* <h1>Invoking Plugin Methods</h1>
* <p>
* Arbitrary plugin methods can be invoked using the
* {@link #invoke(String, String, List, AccessToken, TransactionToken, String)}
* method. The {@link PluginManager} passes these requests to the appropriate
* plugin JVM via the {@code fromServer} {@link InterProccesCommunication
* stream} that was
* setup when the plugin launched.
* </p>
* <h1>Invoking Server Methods</h1>
* <p>
* Plugins are allowed to invoke server methods by placing the appropriate
* request on the {@code fromPlugin} stream. When that happens, the
* PluginManager will send responses back to the plugin on the
* {@code fromServer} channel.
* </p>
* <p>
* <em>It is worth noting that plugins can indirectly communicate with one
* another
* by sending a request to the server to invoke another plugin method.</em>
* </p>
* <h1>Real Time Plugins</h1>
* <p>
* Any plugin that extends {@link RealTimePlugin} will initially receive a
* {@link InterProcesCommunication} segment for real time communication data
* streams. This
* is a one-way stream. Plugins are responsible for decide when and how to
* respond to data that is streamed over.
* </p>
*
* @author Jeff Nelson
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public class PluginManager {
/**
* Return the correct temporary directory that should be used to store
* temporary files (i.e. shared memory segments, etc) for the
* {@code plugin}.
*
* @param plugin the fully qualified name of the plugin
* @return the temporary directory that should be used for the
* {@code plugin}
*/
private static String getPluginTempDirectory(String plugin) {
Path baseTempDir = Paths.get(FileSystem.tempFile()).toFile()
.getParentFile().toPath();
Path sessionTempDir = baseTempDir.resolve(SESSID);
Path pluginTempDir = sessionTempDir.resolve(plugin);
return pluginTempDir.toString();
}
/**
* The number of bytes in a MiB.
*/
private static final long BYTES_PER_MB = 1048576;
/**
* The name of the manifest file that should be included with every plugin.
*/
private static String MANIFEST_FILE = "manifest.json";
/**
* A collection of jar files that exist on the server's native classpath. We
* keep track of these so that we don't unnecessarily search them for
* plugins.
*/
private static Set<String> SYSTEM_JARS;
static {
SYSTEM_JARS = Sets.newHashSet();
ClassLoader cl = PluginManager.class.getClassLoader();
if(cl instanceof URLClassLoader) {
for (URL url : ((URLClassLoader) cl).getURLs()) {
String filename = MorePaths.get(url.getFile()).getFileName()
.toString();
if(filename.endsWith(".jar")) {
SYSTEM_JARS.add(filename);
}
}
}
Reflections.log = null;
}
/**
* Map of aliases names and its respective plugin id.
*/
private Map<String, String> aliases = Maps.newHashMap();
/**
* List of aliases that are restricted to use and are set as ambiguous names
* to use.
*/
private Set<String> ambiguous = Sets.newHashSet();
/**
* A collection of each of the install bundles, mapped to the associated
* {@link Version}.
*/
private Map<String, Version> bundles = Maps.newHashMap();
/**
* {@link ExecutorService} to stream {@link Packet packets} asynchronously.
*/
private final ExecutorService executor = Executors
.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
/**
* The directory of plugins that are managed by this {@link PluginManager}.
*/
private final String home;
/**
* A table that contains metadata about the plugins managed herewithin:
* <ul>
* <li>class (primary key)</li>
* <li>bundle</li>
* <li>fromPlugin</li>
* <li>fromPluginResponses</li>
* <li>fromServer</li>
* <li>appInstance</li>
* <li>status</li>
* </ul>
*/
private final Table<String, RegistryData, Object> registry = HashBasedTable
.create();
// TODO make the plugin launcher watch the directory for changes/additions
// and when new plugins are added, it should launch them
/**
* A flag that indicates if the manager is running or not.
*/
private boolean running = false;
/**
* Responsible for taking arbitrary objects and turning them into binary so
* they can be sent across the wire.
*/
private final PluginSerializer serializer = new PluginSerializer();
/**
* The host server in which this {@link PluginManager} runs.
*/
private final ConcourseServer server;
/**
* The thread that loops through the {@link GlobalState#BINARY_QUEUE} to get
* writes that must be streamed to real time plugins
*/
private final Thread streamLoop;
/**
* All the {@link InterProcessCommunication streams} for which real time
* data updates are sent.
*/
private final Set<InterProcessCommunication> streams = Collections
.newSetFromMap(Maps
.<InterProcessCommunication, Boolean> newConcurrentMap());
/**
* The template to use when creating {@link JavaApp external java processes}
* to run the plugin code.
*/
private String pluginLaunchClassTemplate;
/**
* The session id for the {@link PluginManager}. This is used for grouping
* shared memory files.
*/
private final static String SESSID = Long.toString(Time.now());
/**
* Construct a new instance.
*
* @param directory
*/
public PluginManager(ConcourseServer server, String directory) {
this.server = server;
this.home = Paths.get(directory).toAbsolutePath().toString();
this.streamLoop = new Thread(() -> {
outer: while (true && !Thread.interrupted()) {
// The stream loop continuously checks the BINARY_QUEUE
// for new writes to stream to all the RealTime plugins.
List<WriteEvent> events = Lists.newArrayList();
try {
Queues.blockingDrain(BINARY_QUEUE, events);
}
catch (InterruptedException e) {
// Assume that the #stop routine is interrupting because it
// wants this thread to terminate.
break;
}
if(streams.size() > 0) {
final Packet packet = new Packet(events);
Logger.debug(
"Streaming packet to real-time " + "plugins: {}",
packet);
final ByteBuffer data = serializer.serialize(packet);
List<Future<InterProcessCommunication>> awaiting = Lists
.newArrayList();
for (InterProcessCommunication stream : streams) {
awaiting.add(executor.submit(() -> stream.write(data)));
}
for (Future<InterProcessCommunication> status : awaiting) {
// Ensure that the Packet was written to all the streams
// before looping again so that Packets are not sent out
// of order.
try {
status.get();
}
catch (InterruptedException e) {
// Assume that the #stop routine is interrupting
// because it wants this thread to terminate.
break outer;
}
catch (ExecutionException e) {
Logger.error("Exeception occurred while streaming "
+ "data to a plugin: ", e);
}
}
}
else {
Logger.debug(
"No real-time plugins are installed "
+ "but the following events have been "
+ "drained from the BINARY_QUEUE: {}",
events);
}
}
});
streamLoop.setName("plugin-manager-stream-loop");
streamLoop.setDaemon(true);
Runtime.getRuntime().addShutdownHook(new Thread(() -> stop()));
}
/**
* Install the plugin bundle located within a zip file to the {@link #home}
* directory.
*
* @param bundle the path to the plugin bundle
*/
public void installBundle(String bundle) {
String basename = com.google.common.io.Files
.getNameWithoutExtension(bundle);
String name = null;
try {
String manifest = ZipFiles.getEntryContent(bundle,
basename + File.separator + MANIFEST_FILE);
JsonObject json = (JsonObject) new JsonParser().parse(manifest);
name = json.get("bundleName").getAsString();
File dest = new File(home + File.separator + name);
if(!dest.exists()) {
File src = new File(home + File.separator + basename);
ZipFiles.unzip(bundle, home);
src.renameTo(dest);
Logger.info("Installed the plugins from {} at {}", bundle,
dest.getAbsolutePath());
activate(name, ActivationType.INSTALL);
}
else {
String message = name + " is already installed. "
+ "Please use the upgrade option to install a newer "
+ "version.";
name = null;
throw new IllegalStateException(message);
}
}
catch (Exception e) {
Logger.error("Plugin bundle installation error:", e);
Throwable cause = null;
if((cause = e.getCause()) != null
&& cause instanceof ZipException) {
throw new PluginInstallException(
bundle + " is not a valid plugin bundle: "
+ cause.getMessage());
}
else {
if(name != null) {
// Likely indicates that there was a problem with
// activation, so run uninstall path so things are not in a
// weird state
uninstallBundle(name);
}
throw e; // re-throw exception so CLI fails
}
}
}
/**
* Invoke {@code method} that is defined in the plugin endpoint inside of
* {@clazz}. The provided {@code creds}, {@code transaction} token and
* {@code environment} are used to ensure proper alignment with the
* corresponding client session on the server.
*
* @param plugin class or alias name of the {@link Plugin}
* @param method the name of the method to invoke
* @param args a list of arguments to pass to the method
* @param creds the {@link AccessToken} submitted to ConcourseServer via the
* invokePlugin method
* @param transaction the {@link TransactionToken} submitted to
* ConcourseServer via
* the invokePlugin method
* @param environment the environment submitted to ConcourseServer via the
* invokePlugin method
* @return the response from the plugin
*/
public ComplexTObject invoke(String plugin, String method,
List<ComplexTObject> args, final AccessToken creds,
TransactionToken transaction, String environment) {
String clazz = getIdByAlias(plugin);
InterProcessCommunication fromServer = (InterProcessCommunication) registry
.get(clazz, RegistryData.FROM_SERVER);
if(fromServer == null) {
String message = ambiguous.contains(plugin)
? "Multiple plugins are "
+ "configured to use the alias '{}' so it is not permitted. "
+ "Please invoke the plugin using its full qualified name"
: "No plugin with id or alias {} exists";
throw new PluginException(Strings.format(message, clazz));
}
RemoteMethodRequest request = new RemoteMethodRequest(method, creds,
transaction, environment, args);
ByteBuffer buffer = serializer.serialize(request);
fromServer.write(buffer);
ConcurrentMap<AccessToken, RemoteMethodResponse> fromPluginResponses = (ConcurrentMap<AccessToken, RemoteMethodResponse>) registry
.get(clazz, RegistryData.FROM_PLUGIN_RESPONSES);
RemoteMethodResponse response = ConcurrentMaps
.waitAndRemove(fromPluginResponses, creds);
if(!response.isError()) {
return response.response;
}
else {
throw Throwables.propagate(response.error);
}
}
/**
* Return the names of all the plugins available in the {@link #home}
* directory.
*
* @return the available plugins
*/
public Set<String> listBundles() {
return FileSystem.getSubDirs(home);
}
/**
* Start the plugin manager.
* <p>
* This also starts to stream {@link Packet} in separate thread
* </p>
*/
public void start() {
if(!running) {
running = true;
streamLoop.start();
pluginLaunchClassTemplate = FileSystem.read(
Resources.getAbsolutePath("/META-INF/ConcoursePlugin.tpl"));
for (String bundle : FileSystem.getSubDirs(home)) {
activate(bundle, ActivationType.START);
}
}
}
/**
* Stop the plugin manager and shutdown any managed plugins that are
* running.
*/
public void stop() {
streamLoop.interrupt();
executor.shutdownNow();
for (String id : registry.rowKeySet()) {
JavaApp app = (JavaApp) registry.get(id, RegistryData.APP_INSTANCE);
app.destroy();
}
registry.clear();
running = false;
}
/**
* Uninstall the plugin {@code bundle}
*
* @param bundle the name of the plugin bundle
*/
public void uninstallBundle(String bundle) {
// TODO implement me
/*
* make sure all the plugins in the bundle are stopped delete the bundle
* directory. Will need to add a shutdown(plugin) method. And in
* shutdown if there were no real time streams, then we should set
* streamEnabled to false
*/
FileSystem.deleteDirectory(home + File.separator + bundle);
}
/**
* Activating a {@code bundle} means that all the plugins with the bundle
* are loaded from disk and stored within the {@link #registry}. Depending
* on the {@code type} some pre-launch hooks may be run. If all those hooks
* are successful, each of the plugins in the bundle are
* {@link #launch(String, Path, Class, List) launched}.
*
* @param bundle the name of the plugin bundle
* @param type the {@link ActivationType type} of activation
*/
protected void activate(String bundle, ActivationType type) {
Logger.debug("Activating plugins from {}", bundle);
Path home = Paths.get(this.home, bundle);
Path lib = home.resolve("lib");
Path prefs = home.resolve("conf")
.resolve(PluginConfiguration.PLUGIN_PREFS_FILENAME);
Path prefsDev = home.resolve("conf")
.resolve(PluginConfiguration.PLUGIN_PREFS_DEV_FILENAME);
if(Files.exists(prefsDev)) {
prefs = prefsDev;
prefsDev = null;
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(lib)) {
Iterator<Path> jars = stream.iterator();
// Go through all the jars in the plugin's lib directory and compile
// the appropriate classpath while identifying jars that might
// contain plugin endpoints.
List<URL> urls = Lists.newArrayList();
List<String> classpath = Lists.newArrayList();
while (jars.hasNext()) {
String filename = jars.next().getFileName().toString();
Path path = lib.resolve(filename);
URL url = new File(path.toString()).toURI().toURL();
if(!SYSTEM_JARS.contains(filename)
|| type.mightRequireHooks()) {
// NOTE: by checking for exact name matches, we will
// accidentally include system jars that contain different
// versions.
// NOTE: if a hook must be run, we have to include all the
// jars (including system ones) so that the full context in
// which the hook was written is available.
urls.add(url);
}
classpath.add(url.getFile());
}
// Create a ClassLoader that only contains jars with possible plugin
// endpoints and search for any applicable classes.
URLClassLoader loader = new URLClassLoader(urls.toArray(new URL[0]),
null);
Class parent = loader.loadClass(Plugin.class.getName());
Class realTimeParent = loader
.loadClass(RealTimePlugin.class.getName());
Reflections reflection = new Reflections(
new ConfigurationBuilder().addClassLoader(loader)
.addUrls(ClasspathHelper.forClassLoader(loader)));
Set<Class<?>> subTypes = reflection.getSubTypesOf(parent);
Iterable<Class<?>> plugins = subTypes.stream()
.filter((clz) -> !clz.isInterface() && !Modifier
.isAbstract(clz.getModifiers()))::iterator;
JsonObject manifest = loadBundleManifest(bundle);
Version version = Versions.parseSemanticVersion(
manifest.get("bundleVersion").getAsString());
for (final Class<?> plugin : plugins) {
boolean launch = true;
List<Throwable> errors = Lists.newArrayListWithCapacity(0);
// Depending on the activation type, we may need to run some
// hooks to determine if the plugins from the bundle can
// actually be launched
if(type.mightRequireHooks()) {
try {
Class contextClass = loader
.loadClass(PluginContext.class.getName());
Constructor contextConstructor = contextClass
.getDeclaredConstructor(Path.class,
String.class, String.class);
contextConstructor.setAccessible(true);
String concourseVersion = Versions.parseSemanticVersion(
com.cinchapi.concourse.util.Version
.getVersion(ConcourseServer.class)
.toString())
.toString();
Object context = contextConstructor.newInstance(home,
version.toString(), concourseVersion);
Class iface;
switch (type) {
case INSTALL:
default:
iface = loader.loadClass(
AfterInstallHook.class.getName());
break;
}
Set<Class<?>> potential = reflection
.getSubTypesOf(iface);
Iterable<Class<?>> hooks = potential.stream()
.filter((hook) -> !hook.isInterface()
&& !Modifier.isAbstract(
hook.getModifiers()))::iterator;
for (Class<?> hook : hooks) {
Logger.info("Running hook '{}' for plugin '{}'",
hook.getName(), plugin);
Object instance = Reflection.newInstance(hook);
Reflection.call(instance, "run", context);
}
}
catch (Exception e) {
Throwable error = Throwables.getRootCause(e);
Logger.error("Could not run {} hook for {}:", type,
plugin, error);
launch = false;
errors.add(error);
}
}
if(launch && errors.isEmpty()) {
launch(bundle, prefs, plugin, classpath);
startEventLoop(plugin.getName());
if(realTimeParent.isAssignableFrom(plugin)) {
startStreamToPlugin(plugin.getName());
}
}
else {
// Depending on the activation type, we respond differently
// to a pre-activation error. Plugins within a bundle are
// all or nothing, so if one of them fails the
// pre-activation checks then the entire bundle must suffer
// to consequences.
if(type == ActivationType.INSTALL) {
Logger.error("Errors occurred when trying to "
+ "install {}: {}", bundle, errors);
throw new PluginInstallException("Could not install "
+ bundle + " due to the following errors: "
+ errors);
}
else {
Logger.error("An error occurred when trying to "
+ "activate {}", bundle);
// TODO: call deactivate(bundle) whenever that method is
// ready
}
break;
}
}
bundles.put(bundle, version);
}
catch (IOException | ClassNotFoundException e) {
Logger.error(
"An error occurred while trying to activate the plugin bundle '{}'",
bundle, e);
throw Throwables.propagate(e);
}
}
/**
* Returns the plugin registered for this alias. If unregistered, input
* alias name is returned.
*
* @param alias
* @return String plugin id or alias name.
*/
private String getIdByAlias(String alias) {
return aliases.getOrDefault(alias, alias);
}
/**
* <p>
* This method is called from {@link #activate(String, ActivationType)} once
* any pre-launch checks have successfully completed.
* </p>
* <p>
* Launch the {@code plugin} from {@code dist} within a separate JVM
* configured with the specified {@code classpath} and the values from the
* {@code prefs} file.
* </p>
*
* @param bundle the bundle directory that contains the plugin libraries
* @param prefs the {@link Path} to the config file
* @param plugin the class to launch in a separate JVM
* @param classpath the classpath for the separate JVM
*/
private void launch(final String bundle, final Path prefs,
final Class<?> plugin, final List<String> classpath) {
// Write an arbitrary main class that'll construct the Plugin and run it
String launchClass = plugin.getName();
String launchClassShort = plugin.getSimpleName();
String processName = "Concourse_" + launchClassShort;
String tempDir = getPluginTempDirectory(launchClass);
String fromServer = FileSystem.tempFile(tempDir, "FS-", ".shm");
String fromPlugin = FileSystem.tempFile(tempDir, "FP-", ".shm");
String source = pluginLaunchClassTemplate
.replace("INSERT_PROCESS_NAME", processName)
.replace("INSERT_IMPORT_STATEMENT", launchClass)
.replace("INSERT_FROM_SERVER", fromServer)
.replace("INSERT_FROM_PLUGIN", fromPlugin)
.replace("INSERT_CLASS_NAME", launchClassShort);
// Create an external JavaApp in which the Plugin will run. Get the
// plugin config to size the JVM properly.
PluginConfiguration config = Reflection
.newInstance(StandardPluginConfiguration.class, prefs);
Logger.info("Configuring plugin '{}' from bundle '{}' with "
+ "preferences located in {}", plugin, bundle, prefs);
long heapSize = config.getHeapSize() / BYTES_PER_MB;
for (String alias : config.getAliases()) {
if(!aliases.containsKey(alias) && !ambiguous.contains(alias)) {
aliases.put(alias, plugin.getName());
Logger.info("Registering '{}' as an alias for {}", alias,
plugin);
}
else {
aliases.remove(alias);
ambiguous.add(alias);
Logger.info("Alias '{}' can't be used because it is "
+ "associated with multiple plugins", alias);
}
}
String pluginHome = home + File.separator + bundle;
String serviceToken = BaseEncoding.base32Hex()
.encode(server.newServiceToken().bufferForData().array());
ArrayList<String> options = new ArrayList<String>();
if(config.getRemoteDebuggerEnabled()) {
options.add("-Xdebug");
options.add(
"-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address="
+ config.getRemoteDebuggerPort());
}
options.add("-Xms" + heapSize + "M");
options.add("-Xmx" + heapSize + "M");
options.add("-D" + Plugin.PLUGIN_HOME_JVM_PROPERTY + "=" + pluginHome);
options.add("-D" + Plugin.PLUGIN_SERVICE_TOKEN_JVM_PROPERTY + "="
+ serviceToken);
String cp = StringUtils.join(classpath, JavaApp.CLASSPATH_SEPARATOR);
JavaApp app = new JavaApp(cp, source, options);
app.run();
if(app.isRunning()) {
Logger.info("Starting plugin '{}' from bundle '{}'", launchClass,
bundle);
}
app.onPrematureShutdown((out, err) -> {
try {
List<String> outLines = CharStreams
.readLines(new InputStreamReader(out));
List<String> errLines = CharStreams
.readLines(new InputStreamReader(err));
Logger.warn("Plugin '{}' unexpectedly crashed. ", plugin);
Logger.warn("Standard Output for {}: {}", plugin,
StringUtils.join(outLines, System.lineSeparator()));
Logger.warn("Standard Error for {}: {}", plugin,
StringUtils.join(errLines, System.lineSeparator()));
Logger.warn("Restarting {} now...", plugin);
Iterator<Entry<String, String>> it = aliases.entrySet()
.iterator();
while (it.hasNext()) {
Entry<String, String> entry = it.next();
if(entry.getValue().equals(plugin.getName())) {
it.remove();
}
}
// TODO: it would be nice to just restart the same JavaApp
// instance (e.g. app.restart();)
launch(bundle, prefs, plugin, classpath);
}
catch (IOException e) {
throw Throwables.propagate(e);
}
});
// Ensure that the Plugin is ready to run before adding it to the
// registry to avoid premature invocations
Path readyCheck = com.cinchapi.common.io.Files
.getHashedFilePath(serviceToken);
try {
while (!Files.deleteIfExists(readyCheck)) {
Thread.sleep(1000);
continue;
}
Logger.info("Plugin '{}' is ready", plugin);
}
catch (IOException | InterruptedException e) {}
// Store metadata about the Plugin
String id = launchClass;
registry.put(id, RegistryData.PLUGIN_BUNDLE, bundle);
registry.put(id, RegistryData.FROM_SERVER,
new MessageQueue(fromServer));
registry.put(id, RegistryData.FROM_PLUGIN,
new MessageQueue(fromPlugin));
registry.put(id, RegistryData.STATUS, PluginStatus.ACTIVE);
registry.put(id, RegistryData.APP_INSTANCE, app);
registry.put(id, RegistryData.FROM_PLUGIN_RESPONSES,
Maps.<AccessToken, RemoteMethodResponse> newConcurrentMap());
Logger.debug("Shared memory for server-based communication to '{} is "
+ "located at '{}", id, fromServer);
Logger.debug("Shared memory for plugin-based communication from '{} is "
+ "located at '{}", id, fromPlugin);
}
/**
* Load the {@code bundle}'s manifest from disk as a {@link JsonObject}.
*
* @param bundle the name of the bundle
* @return a JsonObject with all the data in the bundle
*/
private JsonObject loadBundleManifest(String bundle) {
String manifest = FileSystem
.read(Paths.get(home, bundle, MANIFEST_FILE).toString());
return (JsonObject) new JsonParser().parse(manifest);
}
/**
* Start a {@link Thread} that serves as an event loop; processing both
* requests and responses {@code #fromPlugin}.
* <p>
* Requests are forked to a {@link RemoteInvocationThread} for processing.
* </p>
* <p>
* Responses are placed on the appropriate
* {@link RegistryData#FROM_PLUGIN_RESPONSES queue} and listeners are
* notified.
* </p>
*
* @param id the plugin id
* @return the event loop thread
*/
private Thread startEventLoop(String id) {
final InterProcessCommunication incoming = (InterProcessCommunication) registry
.get(id, RegistryData.FROM_PLUGIN);
final InterProcessCommunication outgoing = (InterProcessCommunication) registry
.get(id, RegistryData.FROM_SERVER);
final ConcurrentMap<AccessToken, RemoteMethodResponse> fromPluginResponses = (ConcurrentMap<AccessToken, RemoteMethodResponse>) registry
.get(id, RegistryData.FROM_PLUGIN_RESPONSES);
Thread loop = new Thread(new Runnable() {
@Override
public void run() {
ByteBuffer data;
while ((data = incoming.read()) != null) {
RemoteMessage message = serializer.deserialize(data);
if(message.type() == RemoteMessage.Type.REQUEST) {
RemoteMethodRequest request = (RemoteMethodRequest) message;
Logger.debug("Received REQUEST from Plugin {}: {}", id,
request);
Thread worker = new RemoteInvocationThread(request,
outgoing, server, true, fromPluginResponses);
worker.setUncaughtExceptionHandler(
(thread, throwable) -> {
Logger.error(
"While processing request '{}' from '{}', the following "
+ "non-recoverable error occurred:",
request, id, throwable);
});
worker.start();
}
else if(message.type() == RemoteMessage.Type.RESPONSE) {
RemoteMethodResponse response = (RemoteMethodResponse) message;
Logger.debug("Received RESPONSE from Plugin {}: {}", id,
response);
ConcurrentMaps.putAndSignal(fromPluginResponses,
response.creds, response);
}
else if(message.type() == RemoteMessage.Type.STOP) {
break;
}
else {
// Ignore the message...
continue;
}
}
}
}, "plugin-event-loop-" + id);
loop.setDaemon(true);
loop.start();
return loop;
}
/**
* Create a {@link InterProcessCommunication} segment over which the
* PluginManager will stream real-time {@link Packet packets} that contain
* writes.
*
* @param id the plugin id
*/
private void startStreamToPlugin(String id) {
String tempDir = getPluginTempDirectory(id);
String streamFile = FileSystem.tempFile(tempDir, "RT-", ".shm");
Logger.debug("Creating real-time stream for {} at {}", id, streamFile);
InterProcessCommunication stream = new MessageQueue(streamFile);
Logger.debug("Shared memory for real-time stream of '{} is located at "
+ "'{}", id, streamFile);
RemoteAttributeExchange attribute = new RemoteAttributeExchange(
"stream", streamFile);
InterProcessCommunication fromServer = (InterProcessCommunication) registry
.get(id, RegistryData.FROM_SERVER);
ByteBuffer buffer = serializer.serialize(attribute);
fromServer.write(buffer);
streams.add(stream);
}
/**
* An enum that describes the various reason that the
* {@link #activate(String, ActivationType)} method may be called.
*
* @author Jeff Nelson
*/
private enum ActivationType {
INSTALL, START;
/**
* Return {@code true} if this {@link ActivationType} may require one or
* more hooks to be run.
*
* @return {@code true} if there may be a hook associated with this type
*/
public boolean mightRequireHooks() {
switch (this) {
case INSTALL:
return true;
default:
return false;
}
}
}
/**
* An enum to capture various statuses that plugins can have.
*
* @author Jeff Nelson
*/
private enum PluginStatus {
ACTIVE;
}
/**
* The columns that are included in the {@link #registry} table.
*
* @author Jeff Nelson
*/
private enum RegistryData {
/**
* A reference to the {@link JavaApp} that manages the external JVM
* process for the plugin.
*/
APP_INSTANCE,
/**
* A reference to the {@link SharedMemory} stream that is used by the
* {@link PluginManager} to listen to messages that come from the
* plugin.
*/
FROM_PLUGIN,
/**
* A reference to a {@link ConcurrentMap} that associates an
* {@link AccessToken} to a {@link RemoteMethodResponse}. This
* collection is created for each plugin upon being
* {@link PluginManager#launch(String, Path, Class, List) launched}.
* Whenever a plugin's {@link PluginManager#startEventLoop(String) event
* loop}, which listen for messages on the associated {@code fromPlugin}
* stream, encounters a {@link Instruction#RESPONSE response} to an
* {@link PluginManager#invoke(String, String, List, AccessToken, TransactionToken, String)
* invoke} request, the {@link RemoteMethodResponse response} is placed
* in the map on which the dispatched {@link RemoteInvocationThread
* worker} thread is
* {@link ConcurrentMaps#waitAndRemove(ConcurrentMap, Object) waiting}.
*/
FROM_PLUGIN_RESPONSES,
/**
* A reference to the {@link SharedMemory} stream that is used by the
* {@link PluginManager} to send messages to the plugin. The plugin has
* an event loop that listens on this stream.
*/
FROM_SERVER,
/**
* The name of the bundle in which the plugin is contained. This is
* useful for finding all the plugin that belong to a bundle and need to
* be {@link PluginManager#uninstallBundle(String) uninstalled}.
*/
PLUGIN_BUNDLE,
/**
* A flag that contains the {@link PluginStatus status} for the plugin.
*/
STATUS,
}
}