Automatically load/unload/reload plugins

The PluginScanner thread runs every 1 minute by default and loads any
newly created plugins, unloads any deleted plugins, and reloads any
plugins that have been modified.

It is generally unsafe to delete or modify a JAR that the running
server is using. The standard URLClassLoader might need to dynamically
open the JAR to access a newly referenced class file or resource, and
it often uses a cached table of contents from the JAR's ZIP format.
If someone replaces the JAR on disk, the running URLClassLoader may
try to incorrectly read from it. Discourage this with a warning, but
still permit reloading the plugin whenever it is modified.

Change-Id: I7afb61925235f94c015024d00e0d5830d56d6f2d
This commit is contained in:
Shawn O. Pearce
2012-05-08 21:33:35 -07:00
committed by gerrit code review
parent 4c847cf912
commit 1c748e2564
3 changed files with 183 additions and 77 deletions

View File

@@ -21,11 +21,15 @@ import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.Module;
import org.eclipse.jgit.storage.file.FileSnapshot;
import java.io.File;
import javax.annotation.Nullable;
public class Plugin {
private final String name;
private final ClassLoader loader;
private final FileSnapshot snapshot;
private Class<? extends Module> sysModule;
private Class<? extends Module> sshModule;
@@ -33,11 +37,12 @@ public class Plugin {
private Injector sshInjector;
private LifecycleManager manager;
public Plugin(String name, ClassLoader loader,
public Plugin(String name,
FileSnapshot snapshot,
@Nullable Class<? extends Module> sysModule,
@Nullable Class<? extends Module> sshModule) {
this.name = name;
this.loader = loader;
this.snapshot = snapshot;
this.sysModule = sysModule;
this.sshModule = sshModule;
}
@@ -46,6 +51,10 @@ public class Plugin {
return name;
}
boolean isModified(File jar) {
return snapshot.lastModified() != jar.lastModified();
}
public void start(PluginGuiceEnvironment env) throws Exception {
Injector root = newRootInjector(env);
manager = new LifecycleManager();
@@ -84,10 +93,9 @@ public class Plugin {
public void stop() {
if (manager != null) {
manager.stop();
manager = null;
sysInjector = null;
sshInjector = null;
manager = null;
}
}

View File

@@ -15,28 +15,32 @@
package com.google.gerrit.server.plugins;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gerrit.lifecycle.LifecycleListener;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.inject.Inject;
import com.google.inject.Module;
import com.google.inject.Singleton;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.storage.file.FileSnapshot;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
@@ -48,112 +52,154 @@ public class PluginLoader implements LifecycleListener {
private final File pluginsDir;
private final PluginGuiceEnvironment env;
private final Map<String, Plugin> running;
private final Map<String, FileSnapshot> broken;
private final PluginScannerThread scanner;
@Inject
public PluginLoader(SitePaths sitePaths, PluginGuiceEnvironment pe) {
public PluginLoader(SitePaths sitePaths,
PluginGuiceEnvironment pe,
@GerritServerConfig Config cfg) {
pluginsDir = sitePaths.plugins_dir;
env = pe;
running = Maps.newHashMap();
broken = Maps.newHashMap();
scanner = new PluginScannerThread(
this,
ConfigUtil.getTimeUnit(cfg,
"plugins", null, "checkFrequency",
TimeUnit.MINUTES.toMillis(1), TimeUnit.MILLISECONDS));
}
@Override
public synchronized void start() {
log.info("Loading plugins from " + pluginsDir.getAbsolutePath());
for (Plugin p : scanPlugins()) {
if (running.containsKey(p.getName())) {
log.error("Skipping duplicate plugin " + p.getName());
continue;
}
try {
p.start(env);
} catch (Exception err) {
log.error("Cannot start plugin " + p.getName(), err);
continue;
}
running.put(p.getName(), p);
}
rescan();
scanner.start();
}
@Override
public synchronized void stop() {
for (Plugin p : running.values()) {
p.stop();
public void stop() {
scanner.end();
synchronized (this) {
for (Plugin p : running.values()) {
p.stop();
}
running.clear();
broken.clear();
}
running.clear();
}
private Collection<Plugin> scanPlugins() {
Collection<File> pluginJars;
try {
pluginJars = getPluginFiles();
} catch (IOException e) {
log.error("Cannot scan Gerrit plugins directory looking for jar files", e);
return Collections.emptyList();
}
public synchronized void rescan() {
List<File> jars = scanJarsInPluginsDirectory();
List<Plugin> all = Lists.newArrayListWithCapacity(pluginJars.size());
for (File jarFile : pluginJars) {
stopRemovedPlugins(jars);
for (File jar : jars) {
String name = nameOf(jar);
FileSnapshot brokenTime = broken.get(name);
if (brokenTime != null && !brokenTime.isModified(jar)) {
continue;
}
Plugin active = running.get(name);
if (active != null && !active.isModified(jar)) {
continue;
}
if (active != null) {
log.warn(String.format(
"Detected %s was replaced/overwritten."
+ " This is not a safe way to update a plugin.",
jar.getAbsolutePath()));
log.info(String.format("Reloading plugin %s", name));
active.stop();
running.remove(name);
}
FileSnapshot snapshot = FileSnapshot.save(jar);
Plugin next;
try {
all.add(loadPlugin(jarFile));
} catch (IOException e) {
log.error("Cannot access plugin jar " + jarFile, e);
} catch (ClassNotFoundException e) {
log.error("Cannot load plugin class module from " + jarFile, e);
next = loadPlugin(name, snapshot, jar);
next.start(env);
} catch (Throwable err) {
log.warn(String.format("Cannot load plugin %s", name), err);
broken.put(name, snapshot);
continue;
}
broken.remove(name);
running.put(name, next);
if (active == null) {
log.info(String.format("Loaded plugin %s", name));
}
}
return all;
}
@SuppressWarnings("unchecked")
private Plugin loadPlugin(File jarFile) throws IOException,
ClassNotFoundException {
Manifest jarManifest = new JarFile(jarFile).getManifest();
private void stopRemovedPlugins(List<File> jars) {
Set<String> unload = Sets.newHashSet(running.keySet());
for (File jar : jars) {
unload.remove(nameOf(jar));
}
for (String name : unload){
log.info(String.format("Unloading plugin %s", name));
running.remove(name).stop();
}
}
private static String nameOf(File jar) {
String name = jar.getName();
int ext = name.lastIndexOf('.');
return 0 < ext ? name.substring(0, ext) : name;
}
private Plugin loadPlugin(String name, FileSnapshot snapshot, File jarFile)
throws IOException, ClassNotFoundException {
Manifest manifest = new JarFile(jarFile).getManifest();
Attributes main = manifest.getMainAttributes();
String sysName = main.getValue("Gerrit-Module");
String sshName = main.getValue("Gerrit-SshModule");
URL[] urls = {jarFile.toURI().toURL()};
ClassLoader parentLoader = PluginLoader.class.getClassLoader();
ClassLoader pluginLoader =
new URLClassLoader(getPluginURLs(jarFile), parentLoader);
ClassLoader pluginLoader = new URLClassLoader(urls, parentLoader);
Attributes attrs = jarManifest.getMainAttributes();
String pluginName = attrs.getValue("Gerrit-Plugin");
if (Strings.isNullOrEmpty(pluginName)) {
throw new IOException("No Gerrit-Plugin attribute in manifest");
}
String moduleName = attrs.getValue("Gerrit-SshModule");
if (Strings.isNullOrEmpty(moduleName)) {
throw new IOException("No Gerrit-SshModule attribute in manifest");
}
Class<? extends Module> moduleClass =
(Class<? extends Module>) Class
.forName(moduleName, false, pluginLoader);
if (!Module.class.isAssignableFrom(moduleClass)) {
throw new ClassNotFoundException(String.format(
"Gerrit-SshModule %s is not a Guice Module", moduleClass.getName()));
}
return new Plugin(pluginName, pluginLoader, null, moduleClass);
Class<? extends Module> sysModule = load(sysName, pluginLoader);
Class<? extends Module> sshModule = load(sshName, pluginLoader);
return new Plugin(name, snapshot, sysModule, sshModule);
}
private URL[] getPluginURLs(File jarFile) throws MalformedURLException {
return new URL[] {jarFile.toURI().toURL()};
private Class<? extends Module> load(String name, ClassLoader pluginLoader)
throws ClassNotFoundException {
if (Strings.isNullOrEmpty(name)) {
return null;
}
@SuppressWarnings("unchecked")
Class<? extends Module> clazz =
(Class<? extends Module>) Class.forName(name, false, pluginLoader);
if (!Module.class.isAssignableFrom(clazz)) {
throw new ClassCastException(String.format(
"Class %s does not implement %s",
name, Module.class.getName()));
}
return clazz;
}
private List<File> getPluginFiles() throws IOException {
private List<File> scanJarsInPluginsDirectory() {
if (pluginsDir == null || !pluginsDir.exists()) {
return Collections.emptyList();
}
File[] plugins = pluginsDir.listFiles(new FileFilter() {
File[] matches = pluginsDir.listFiles(new FileFilter() {
@Override
public boolean accept(File pathname) {
return pathname.isFile() && pathname.getName().endsWith(".jar");
return pathname.getName().endsWith(".jar") && pathname.isFile();
}
});
if (plugins == null) {
if (matches == null) {
log.error("Cannot list " + pluginsDir.getAbsolutePath());
return Collections.emptyList();
}
return Arrays.asList(plugins);
return Arrays.asList(matches);
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (C) 2012 The Android Open Source Project
//
// 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.google.gerrit.server.plugins;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
class PluginScannerThread extends Thread {
private final CountDownLatch done = new CountDownLatch(1);
private final PluginLoader loader;
private final long checkFrequencyMillis;
PluginScannerThread(PluginLoader loader, long checkFrequencyMillis) {
this.loader = loader;
this.checkFrequencyMillis = checkFrequencyMillis;
setDaemon(true);
setName("PluginScanner");
}
@Override
public void run() {
for (;;) {
try {
if (done.await(checkFrequencyMillis, TimeUnit.MILLISECONDS)) {
return;
}
} catch (InterruptedException e) {
}
loader.rescan();
}
}
void end() {
done.countDown();
try {
join();
} catch (InterruptedException e) {
}
}
}